diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index becbedf7eb..5723f6d736 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8025,6 +8025,71 @@ "advisory", "block" ] + }, + "autonomy": { + "type": "object", + "properties": { + "review": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + }, + "request_changes": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + }, + "approve": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + }, + "merge": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + }, + "close": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + }, + "label": { + "type": "string", + "enum": [ + "observe", + "suggest", + "propose", + "auto_with_approval", + "auto" + ] + } + } } }, "required": [ diff --git a/migrations/0042_agent_autonomy.sql b/migrations/0042_agent_autonomy.sql new file mode 100644 index 0000000000..d0ddca2743 --- /dev/null +++ b/migrations/0042_agent_autonomy.sql @@ -0,0 +1,5 @@ +-- Agent-layer autonomy dial (#773, Wave 2 Phase 0). Per-action-class autonomy level stored as a JSON map +-- (action class -> observe|suggest|propose|auto_with_approval|auto). Default '{}' = deny-by-default: every +-- action class resolves to `observe` (gittensory watches but never acts) until a maintainer opts in. The +-- single source the action layer (#778) reads via resolveAutonomy. Additive; existing repos are unaffected. +ALTER TABLE repository_settings ADD COLUMN autonomy_json TEXT NOT NULL DEFAULT '{}'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index dd45c4fa9d..c36946214d 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -61,6 +61,7 @@ import type { AgentActionRecord, AgentActionStatus, AgentActionType, + AutonomyPolicy, AgentCommandAnswerRecord, AgentCommandFeedbackRecord, AgentContextSnapshotRecord, @@ -150,6 +151,7 @@ import type { import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; import { classifyMcpClientVersion, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "../services/mcp-compatibility"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; +import { normalizeAutonomyPolicy } from "../settings/autonomy"; import { decryptSecret, encryptSecret, sha256Hex } from "../utils/crypto"; import { jsonString, nowIso, parseJson, repoParts } from "../utils/json"; @@ -422,6 +424,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise privateTrustEnabled: true, badgeEnabled: false, commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, + autonomy: {}, }; } return { @@ -457,6 +460,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise privateTrustEnabled: row.privateTrustEnabled, badgeEnabled: row.badgeEnabled, commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), + autonomy: parseAutonomyPolicy(row.autonomyJson), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -496,6 +500,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial(value, null)).policy; } +function parseAutonomyPolicy(value: string): AutonomyPolicy { + return normalizeAutonomyPolicy(parseJson(value, null)); +} + function parseSyncStatus(value: string): RepoSyncStateRecord["status"] { if ( value === "running" || diff --git a/src/db/schema.ts b/src/db/schema.ts index 4013b3599b..9084302f57 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -71,6 +71,7 @@ export const repositorySettings = sqliteTable("repository_settings", { privateTrustEnabled: integer("private_trust_enabled", { mode: "boolean" }).notNull().default(true), badgeEnabled: integer("badge_enabled", { mode: "boolean" }).notNull().default(false), commandAuthorizationJson: text("command_authorization_json").notNull().default("{}"), + autonomyJson: text("autonomy_json").notNull().default("{}"), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 822b5d2b9e..85ed220558 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -581,6 +581,9 @@ export const RepositorySettingsSchema = z default: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commands: z.record(z.string(), z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"]))), }), + autonomy: z + .record(z.enum(["review", "request_changes", "approve", "merge", "close", "label"]), z.enum(["observe", "suggest", "propose", "auto_with_approval", "auto"])) + .optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) diff --git a/src/settings/autonomy.ts b/src/settings/autonomy.ts new file mode 100644 index 0000000000..e21f8af338 --- /dev/null +++ b/src/settings/autonomy.ts @@ -0,0 +1,50 @@ +import type { AgentActionClass, AutonomyLevel, AutonomyPolicy } from "../types"; + +// The graduated autonomy dial (#773), ordered least → most autonomous. Every later agent-layer phase reads +// this BEFORE acting. `observe` is the deny-by-default floor — gittensory watches but never takes an action. +export const AUTONOMY_LEVELS = ["observe", "suggest", "propose", "auto_with_approval", "auto"] as const; + +// The write-action classes the maintainer auto-maintain layer (#778) can take on a PR. +export const AGENT_ACTION_CLASSES = ["review", "request_changes", "approve", "merge", "close", "label"] as const; + +// Deny-by-default: any action class with no explicit, valid level resolves to this. +export const DEFAULT_AUTONOMY_LEVEL: AutonomyLevel = "observe"; + +const AUTONOMY_LEVEL_SET = new Set(AUTONOMY_LEVELS); + +/** + * Resolve the configured autonomy level for one action class on a repo. THE single gate the action layer + * (#778) consults before any write action. Deny-by-default: an unset (or malformed) action class is + * `observe` — gittensory observes but never acts. Pure. + */ +export function resolveAutonomy(autonomy: AutonomyPolicy | null | undefined, actionClass: AgentActionClass): AutonomyLevel { + return autonomy?.[actionClass] ?? DEFAULT_AUTONOMY_LEVEL; +} + +/** True when the level permits the agent to actually execute the action (directly or behind an approval). */ +export function isActingAutonomyLevel(level: AutonomyLevel): boolean { + return level === "auto" || level === "auto_with_approval"; +} + +/** True when the action must pass a human approval gate (#779) before it executes. */ +export function autonomyRequiresApproval(level: AutonomyLevel): boolean { + return level === "auto_with_approval"; +} + +/** + * Parse/validate an arbitrary value into an AutonomyPolicy: keep only known action classes mapped to known + * levels, drop everything else. Deny-by-default by omission. Used for the DB row, the API body, and the + * `.gittensory.yml` settings block. Pure. + */ +export function normalizeAutonomyPolicy(input: unknown): AutonomyPolicy { + if (typeof input !== "object" || input === null || Array.isArray(input)) return {}; + const record = input as Record; + const policy: AutonomyPolicy = {}; + for (const actionClass of AGENT_ACTION_CLASSES) { + const value = record[actionClass]; + if (typeof value === "string" && AUTONOMY_LEVEL_SET.has(value)) { + policy[actionClass] = value as AutonomyLevel; + } + } + return policy; +} diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 7c4006815a..62c9bfcd12 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -1,5 +1,6 @@ import { parse as parseYaml } from "yaml"; import type { GatePolicyPack, GateRuleMode, JsonValue, RepositorySettings } from "../types"; +import { normalizeAutonomyPolicy } from "../settings/autonomy"; export type FocusManifestSource = "repo_file" | "api_record" | "none"; export type FocusManifestLinkedIssuePolicy = "required" | "preferred" | "optional"; @@ -66,6 +67,7 @@ export type FocusManifestSettings = Partial< | "requireLinkedIssue" | "backfillEnabled" | "privateTrustEnabled" + | "autonomy" > >; @@ -425,6 +427,13 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) const flag = normalizeOptionalBoolean(r[key], `settings.${key}`, warnings); if (flag !== null) out[key] = flag; } + // Agent-layer autonomy dial (#773): `settings.autonomy` maps each action class to a level. Only set it + // when at least one valid class→level pair survives normalization, so a malformed block never blanks the + // DB-configured policy via the resolver's `{...dbSettings, ...manifest.settings}` overlay. + if (r.autonomy !== undefined) { + const autonomy = normalizeAutonomyPolicy(r.autonomy); + if (Object.keys(autonomy).length > 0) out.autonomy = autonomy; + } return out; } diff --git a/src/types.ts b/src/types.ts index f904f8ddc5..2ea5152870 100644 --- a/src/types.ts +++ b/src/types.ts @@ -458,6 +458,10 @@ export type RepositorySettings = { * (default false); optional so existing settings fixtures/callers need not be touched. */ badgeEnabled?: boolean | undefined; commandAuthorization?: RepositoryCommandAuthorizationPolicy | undefined; + /** Agent-layer autonomy dial (#773): per-action-class level. Always populated by the DB layer (default + * `{}` = deny-by-default = "observe" for every class); optional so existing settings fixtures/callers + * need not be touched. The single source the action layer (#778) reads via `resolveAutonomy`. */ + autonomy?: AutonomyPolicy | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; @@ -469,6 +473,17 @@ export type RepositoryCommandAuthorizationPolicy = { commands: Record; }; +/** Agent-layer graduated autonomy (#773), least → most autonomous. `observe` is the deny-by-default floor: + * gittensory watches but never acts. `suggest`/`propose` surface guidance/concrete proposals without + * executing; `auto_with_approval` executes behind a human approval gate (#779); `auto` executes directly. */ +export type AutonomyLevel = "observe" | "suggest" | "propose" | "auto_with_approval" | "auto"; + +/** The write-action classes the maintainer auto-maintain layer (#778) can take on a PR. */ +export type AgentActionClass = "review" | "request_changes" | "approve" | "merge" | "close" | "label"; + +/** Per-action-class autonomy. An unset class resolves to `observe` (deny-by-default). */ +export type AutonomyPolicy = Partial>; + export type RepoSyncStateRecord = { repoFullName: string; status: "never_synced" | "running" | "success" | "partial" | "error" | "skipped" | "capped" | "rate_limited" | "stale"; diff --git a/test/unit/autonomy.test.ts b/test/unit/autonomy.test.ts new file mode 100644 index 0000000000..91f993c6d1 --- /dev/null +++ b/test/unit/autonomy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_ACTION_CLASSES, + AUTONOMY_LEVELS, + DEFAULT_AUTONOMY_LEVEL, + autonomyRequiresApproval, + isActingAutonomyLevel, + normalizeAutonomyPolicy, + resolveAutonomy, +} from "../../src/settings/autonomy"; +import type { AutonomyPolicy } from "../../src/types"; + +describe("resolveAutonomy (#773 deny-by-default gate)", () => { + it("returns the configured level for an action class", () => { + const autonomy: AutonomyPolicy = { merge: "auto_with_approval", label: "auto" }; + expect(resolveAutonomy(autonomy, "merge")).toBe("auto_with_approval"); + expect(resolveAutonomy(autonomy, "label")).toBe("auto"); + }); + + it("denies by default — an unset action class resolves to observe", () => { + expect(resolveAutonomy({ merge: "auto" }, "close")).toBe("observe"); + expect(resolveAutonomy({}, "merge")).toBe(DEFAULT_AUTONOMY_LEVEL); + expect(DEFAULT_AUTONOMY_LEVEL).toBe("observe"); + }); + + it("denies by default for a null/undefined policy (no config at all)", () => { + expect(resolveAutonomy(null, "merge")).toBe("observe"); + expect(resolveAutonomy(undefined, "review")).toBe("observe"); + }); + + it("every action class resolves to observe under an empty policy", () => { + for (const actionClass of AGENT_ACTION_CLASSES) { + expect(resolveAutonomy({}, actionClass)).toBe("observe"); + } + }); +}); + +describe("autonomy level predicates", () => { + it("isActingAutonomyLevel is true only for auto / auto_with_approval", () => { + expect(isActingAutonomyLevel("auto")).toBe(true); + expect(isActingAutonomyLevel("auto_with_approval")).toBe(true); + expect(isActingAutonomyLevel("propose")).toBe(false); + expect(isActingAutonomyLevel("suggest")).toBe(false); + expect(isActingAutonomyLevel("observe")).toBe(false); + }); + + it("autonomyRequiresApproval is true only for auto_with_approval", () => { + expect(autonomyRequiresApproval("auto_with_approval")).toBe(true); + expect(autonomyRequiresApproval("auto")).toBe(false); + expect(autonomyRequiresApproval("observe")).toBe(false); + }); + + it("the level ladder is ordered observe → … → auto with observe at the floor", () => { + expect(AUTONOMY_LEVELS[0]).toBe("observe"); + expect(AUTONOMY_LEVELS[AUTONOMY_LEVELS.length - 1]).toBe("auto"); + expect(AUTONOMY_LEVELS).toEqual(["observe", "suggest", "propose", "auto_with_approval", "auto"]); + }); +}); + +describe("normalizeAutonomyPolicy", () => { + it("keeps only known action classes mapped to known levels", () => { + expect(normalizeAutonomyPolicy({ merge: "auto", review: "suggest" })).toEqual({ merge: "auto", review: "suggest" }); + }); + + it("drops unknown action classes and unknown levels (deny-by-omission)", () => { + expect( + normalizeAutonomyPolicy({ merge: "auto", deploy: "auto", close: "rampage", label: 7 }), + ).toEqual({ merge: "auto" }); + }); + + it("returns an empty policy for non-object / array / null input", () => { + expect(normalizeAutonomyPolicy(null)).toEqual({}); + expect(normalizeAutonomyPolicy("auto")).toEqual({}); + expect(normalizeAutonomyPolicy(["merge"])).toEqual({}); + expect(normalizeAutonomyPolicy(undefined)).toEqual({}); + }); + + it("round-trips a valid policy through normalization", () => { + const policy: AutonomyPolicy = { review: "propose", request_changes: "auto_with_approval", merge: "observe" }; + expect(normalizeAutonomyPolicy(policy)).toEqual(policy); + }); +}); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 0e00649c18..daa431ae57 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -238,6 +238,7 @@ describe("data spine repositories", () => { publicSurface: "comment_and_label", gatePack: "gittensor", slopGateMode: "off", + autonomy: {}, // #773 deny-by-default: no autonomy configured for a missing repo }); // gatePack (#692) round-trips and defaults to gittensor. await upsertRepositorySettings(env, { repoFullName: "owner/repo", gatePack: "oss-anti-slop" }); @@ -260,6 +261,12 @@ describe("data spine repositories", () => { const updated = await getRepositorySettings(env, "owner/sloprepo"); expect(updated.slopGateMode).toBe("advisory"); expect(updated.slopGateMinScore).toBe(40); + // #773 agent autonomy round-trips (insert + update), drops invalid entries, and defaults to {}. + await upsertRepositorySettings(env, { repoFullName: "owner/autonomyrepo", autonomy: { merge: "auto_with_approval", label: "auto", deploy: "auto" } as never }); + expect((await getRepositorySettings(env, "owner/autonomyrepo")).autonomy).toEqual({ merge: "auto_with_approval", label: "auto" }); + await upsertRepositorySettings(env, { repoFullName: "owner/autonomyrepo", autonomy: { merge: "observe" } }); + expect((await getRepositorySettings(env, "owner/autonomyrepo")).autonomy).toEqual({ merge: "observe" }); // update persists + expect((await getRepositorySettings(env, "owner/defaultpack")).autonomy).toEqual({}); // deny-by-default expect(updated.slopAiAdvisory).toBe(false); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 331c102647..baaa710361 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -909,6 +909,16 @@ describe("parseFocusManifest settings override + resolveEffectiveSettings", () = expect(settingsOverrideToJson(parseFocusManifest({}).settings)).toBeNull(); }); + it("parses + resolves agent autonomy from the settings: block, dropping invalid entries (#773)", () => { + const manifest = parseFocusManifest({ settings: { autonomy: { merge: "auto", close: "auto_with_approval", deploy: "auto", label: "nope" } } }); + expect(manifest.settings.autonomy).toEqual({ merge: "auto", close: "auto_with_approval" }); // unknown class + invalid level dropped + const eff = resolveEffectiveSettings({ autonomy: { review: "observe" } } as unknown as RepositorySettings, manifest); + expect(eff.autonomy).toEqual({ merge: "auto", close: "auto_with_approval" }); // yml overlays DB + // A malformed/empty autonomy block never blanks the DB-configured policy. + const noOverride = resolveEffectiveSettings({ autonomy: { merge: "auto" } } as unknown as RepositorySettings, parseFocusManifest({ settings: { autonomy: { bogus: "x" } } })); + expect(noOverride.autonomy).toEqual({ merge: "auto" }); + }); + it("resolveEffectiveSettings overlays settings: over DB and lets gate: win for gate fields", () => { const db = { commentMode: "off", gateCheckMode: "off", linkedIssueGateMode: "off", duplicatePrGateMode: "off", autoLabelEnabled: true } as unknown as RepositorySettings; const eff = resolveEffectiveSettings(