feat: add builtin role profiles

This commit is contained in:
Keisuke Hirata 2026-06-11 00:11:39 +09:00
parent a901ebeb4f
commit 85c06dc62e
No known key found for this signature in database
11 changed files with 218 additions and 246 deletions

View File

@ -1,21 +1 @@
default = "project:companion"
[profile.companion]
description = "Companion role profile: GPT-5.5 with bundled default behavior"
path = "profiles/companion.lua"
[profile.intake]
description = "Intake role profile: GPT-5.5 with bundled default behavior"
path = "profiles/intake.lua"
[profile.orchestrator]
description = "Orchestrator role profile: GPT-5.5 with bundled default behavior"
path = "profiles/orchestrator.lua"
[profile.coder]
description = "Coder role profile: GPT-5.5 with bundled default behavior"
path = "profiles/coder.lua"
[profile.reviewer]
description = "Reviewer role profile: GPT-5.5 with bundled default behavior"
path = "profiles/reviewer.lua"
default = "builtin:companion"

View File

@ -1,55 +0,0 @@
local profile = require("yoi.profile")
local scope = require("yoi.scope")
local compact = require("yoi.compact")
return function(opts)
return profile {
slug = opts.slug,
description = opts.description,
scope = opts.scope or scope.workspace_read(),
delegation_scope = opts.delegation_scope,
session = {
record_event_trace = true,
},
worker = {
reasoning = "high",
language = opts.language or "Japanese",
},
model = {
ref = opts.model_ref,
},
compaction = compact.tokens {
threshold = 240000,
request_threshold = 270000,
worker_context_max_tokens = 100000,
},
feature = opts.feature or {
task = { enabled = true },
memory = { enabled = true },
web = { enabled = true },
pods = { enabled = false },
ticket = { enabled = false, access = "lifecycle" },
ticket_orchestration = { enabled = false },
},
memory = {
extract_threshold = 50000,
consolidation_threshold_files = 5,
consolidation_threshold_bytes = 50000,
},
web = {
enabled = true,
search = {
provider = "brave",
api_key_secret = "web/brave/default",
},
},
}
end

View File

@ -6,17 +6,17 @@ root = ".yoi/tickets"
language = "Japanese"
[roles.intake]
profile = "project:intake"
profile = "builtin:intake"
workflow = "ticket-intake-workflow"
[roles.orchestrator]
profile = "project:orchestrator"
profile = "builtin:orchestrator"
workflow = "ticket-orchestrator-routing"
[roles.coder]
profile = "project:coder"
profile = "builtin:coder"
workflow = "multi-agent-workflow"
[roles.reviewer]
profile = "project:reviewer"
profile = "builtin:reviewer"
workflow = "multi-agent-workflow"

View File

@ -1035,7 +1035,7 @@ profile = "builtin:default"
))
.unwrap();
assert_eq!(intake.role, TicketRole::Intake);
assert_eq!(intake.profile, "builtin:default");
assert_eq!(intake.profile, TicketRole::Intake.default_profile());
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
@ -1044,7 +1044,10 @@ profile = "builtin:default"
))
.unwrap();
assert_eq!(orchestrator.role, TicketRole::Orchestrator);
assert_eq!(orchestrator.profile, "builtin:default");
assert_eq!(
orchestrator.profile,
TicketRole::Orchestrator.default_profile()
);
assert_eq!(
orchestrator.workflow,
TicketRole::Orchestrator.default_workflow()

View File

@ -25,9 +25,61 @@ use crate::{
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
const BUILTIN_DEFAULT_PROFILE: &str = include_str!("../../../resources/profiles/default.lua");
const BUILTIN_COMPANION_PROFILE: &str = include_str!("../../../resources/profiles/companion.lua");
const BUILTIN_INTAKE_PROFILE: &str = include_str!("../../../resources/profiles/intake.lua");
const BUILTIN_ORCHESTRATOR_PROFILE: &str =
include_str!("../../../resources/profiles/orchestrator.lua");
const BUILTIN_CODER_PROFILE: &str = include_str!("../../../resources/profiles/coder.lua");
const BUILTIN_REVIEWER_PROFILE: &str = include_str!("../../../resources/profiles/reviewer.lua");
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
struct BuiltinProfile {
name: &'static str,
label: &'static str,
content: &'static str,
description: &'static str,
}
const BUILTIN_PROFILES: &[BuiltinProfile] = &[
BuiltinProfile {
name: BUILTIN_DEFAULT_PROFILE_NAME,
label: "builtin:default",
content: BUILTIN_DEFAULT_PROFILE,
description: "Bundled default Yoi coding profile",
},
BuiltinProfile {
name: "companion",
label: "builtin:companion",
content: BUILTIN_COMPANION_PROFILE,
description: "Bundled Companion role profile",
},
BuiltinProfile {
name: "intake",
label: "builtin:intake",
content: BUILTIN_INTAKE_PROFILE,
description: "Bundled Intake role profile",
},
BuiltinProfile {
name: "orchestrator",
label: "builtin:orchestrator",
content: BUILTIN_ORCHESTRATOR_PROFILE,
description: "Bundled Orchestrator role profile",
},
BuiltinProfile {
name: "coder",
label: "builtin:coder",
content: BUILTIN_CODER_PROFILE,
description: "Bundled Coder role profile",
},
BuiltinProfile {
name: "reviewer",
label: "builtin:reviewer",
content: BUILTIN_REVIEWER_PROFILE,
description: "Bundled Reviewer role profile",
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileRegistrySource {
@ -798,14 +850,16 @@ fn find_project_profiles_from(start: &Path) -> Option<PathBuf> {
}
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
for profile in BUILTIN_PROFILES {
registry.push_entry(ProfileRegistryEntry::embedded(
ProfileRegistrySource::Builtin,
BUILTIN_DEFAULT_PROFILE_NAME,
"builtin:default",
BUILTIN_DEFAULT_PROFILE,
Some("Bundled default Yoi coding profile".into()),
profile.name,
profile.label,
profile.content,
Some(profile.description.into()),
));
}
}
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
if let Some((prefix, name)) = raw.split_once(':')
@ -1010,15 +1064,18 @@ fn profile_module(lua: &Lua) -> mlua::Result<Table> {
Ok(module)
}
fn import_profile_artifact(lua: &Lua, reference: &str) -> mlua::Result<LuaValue> {
let source = match reference {
"builtin:default" | "default" => BUILTIN_DEFAULT_PROFILE,
other => {
return Err(mlua::Error::RuntimeError(format!(
"unsupported profile import `{other}`"
)));
let profile = builtin_profile_by_ref(reference).ok_or_else(|| {
mlua::Error::RuntimeError(format!("unsupported profile import `{reference}`"))
})?;
lua.load(profile.content)
.set_name(profile.label)
.eval::<LuaValue>()
}
};
lua.load(source).set_name(reference).eval::<LuaValue>()
fn builtin_profile_by_ref(reference: &str) -> Option<&'static BuiltinProfile> {
let name = reference.strip_prefix("builtin:").unwrap_or(reference);
BUILTIN_PROFILES
.iter()
.find(|profile| profile.name == name || profile.label == reference)
}
fn deep_merge_profile_json(base: &mut serde_json::Value, overrides: serde_json::Value) {
match (base, overrides) {
@ -1458,6 +1515,98 @@ mod tests {
assert_eq!(default.path, None);
assert_eq!(default.provenance, "builtin:default");
}
#[test]
fn builtin_role_profiles_are_registered_and_resolve() {
let tmp = TempDir::new().unwrap();
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
for expected in ["companion", "intake", "orchestrator", "coder", "reviewer"] {
let entry = registry
.select(&ProfileSelector::source_named(
ProfileRegistrySource::Builtin,
expected,
))
.unwrap();
assert_eq!(entry.source, ProfileRegistrySource::Builtin);
assert_eq!(entry.path, None);
assert_eq!(entry.provenance, format!("builtin:{expected}"));
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
ProfileResolveOptions::with_pod_name("role-pod"),
)
.unwrap();
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
Some(expected)
);
assert_eq!(resolved.manifest.pod.name, "role-pod");
}
}
#[test]
fn builtin_role_profiles_preserve_role_tool_policy() {
let tmp = TempDir::new().unwrap();
let resolve = |role: &str| {
ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
ProfileResolveOptions::with_pod_name("role-pod"),
)
.unwrap()
.manifest
};
let companion = resolve("companion");
assert!(!companion.feature.task.enabled);
assert!(!companion.feature.pods.enabled);
assert!(!companion.feature.ticket.enabled);
assert_eq!(companion.scope.allow[0].permission, Permission::Read);
assert!(companion.model.ref_.is_none());
assert!(companion.web.is_none());
let intake = resolve("intake");
assert!(!intake.feature.task.enabled);
assert!(!intake.feature.pods.enabled);
assert!(intake.feature.ticket.enabled);
assert_eq!(intake.scope.allow[0].permission, Permission::Read);
assert!(intake.model.ref_.is_none());
assert!(intake.web.is_none());
assert!(!intake.feature.ticket_orchestration.enabled);
let orchestrator = resolve("orchestrator");
assert!(!orchestrator.feature.task.enabled);
assert!(orchestrator.feature.pods.enabled);
assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket_orchestration.enabled);
assert_eq!(orchestrator.scope.allow[0].permission, Permission::Read);
assert!(orchestrator.model.ref_.is_none());
assert!(orchestrator.web.is_none());
assert_eq!(
orchestrator.delegation_scope.allow[0].permission,
Permission::Write
);
let coder = resolve("coder");
assert!(!coder.feature.task.enabled);
assert!(!coder.feature.pods.enabled);
assert_eq!(coder.scope.allow[0].permission, Permission::Write);
assert!(coder.model.ref_.is_none());
assert!(coder.web.is_none());
let reviewer = resolve("reviewer");
assert!(!reviewer.feature.task.enabled);
assert!(!reviewer.feature.pods.enabled);
assert!(!reviewer.feature.ticket.enabled);
assert_eq!(reviewer.scope.allow[0].permission, Permission::Read);
assert!(reviewer.model.ref_.is_none());
assert!(reviewer.web.is_none());
}
#[test]
fn profile_resolution_requires_runtime_pod_name() {
let tmp = TempDir::new().unwrap();
@ -1932,112 +2081,6 @@ language = "nested"
assert!(matches!(err, ProfileError::UnsupportedProfileType { .. }));
assert!(err.to_string().contains("Lua profiles must end in .lua"));
}
#[test]
fn actual_project_role_profiles_resolve_explicit_feature_defaults() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let profiles_dir = tmp.path().join(".yoi/profiles");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&profiles_dir).unwrap();
for (name, content) in [
(
"_base.lua",
include_str!("../../../.yoi/profiles/_base.lua"),
),
(
"coder.lua",
include_str!("../../../.yoi/profiles/coder.lua"),
),
(
"intake.lua",
include_str!("../../../.yoi/profiles/intake.lua"),
),
(
"orchestrator.lua",
include_str!("../../../.yoi/profiles/orchestrator.lua"),
),
(
"reviewer.lua",
include_str!("../../../.yoi/profiles/reviewer.lua"),
),
(
"companion.lua",
include_str!("../../../.yoi/profiles/companion.lua"),
),
] {
std::fs::write(profiles_dir.join(name), content).unwrap();
}
struct Expected {
role: &'static str,
ticket: bool,
ticket_orchestration: bool,
pods: bool,
}
for expected in [
Expected {
role: "orchestrator",
ticket: true,
ticket_orchestration: true,
pods: true,
},
Expected {
role: "coder",
ticket: false,
ticket_orchestration: false,
pods: false,
},
Expected {
role: "intake",
ticket: true,
ticket_orchestration: false,
pods: false,
},
Expected {
role: "reviewer",
ticket: false,
ticket_orchestration: false,
pods: false,
},
Expected {
role: "companion",
ticket: false,
ticket_orchestration: false,
pods: false,
},
] {
let resolved = ProfileResolver::new()
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(profiles_dir.join(format!("{}.lua", expected.role))),
ProfileResolveOptions::with_pod_name(format!("{}-pod", expected.role)),
)
.unwrap();
let feature = &resolved.manifest.feature;
assert!(
!feature.task.enabled,
"{} profile must explicitly keep Task tools disabled",
expected.role
);
assert_eq!(
feature.ticket.enabled, expected.ticket,
"{} ticket feature default mismatch",
expected.role
);
assert_eq!(
feature.ticket_orchestration.enabled, expected.ticket_orchestration,
"{} ticket orchestration feature default mismatch",
expected.role
);
assert_eq!(
feature.pods.enabled, expected.pods,
"{} Pod feature default mismatch",
expected.role
);
}
}
#[test]
fn discovery_reads_user_and_project_registry_and_project_default_wins() {
let tmp = TempDir::new().unwrap();

View File

@ -16,8 +16,6 @@ use thiserror::Error;
pub const TICKET_CONFIG_RELATIVE_PATH: &str = ".yoi/ticket.config.toml";
/// Workspace-relative default root for the built-in local Ticket backend.
pub const DEFAULT_TICKET_BACKEND_RELATIVE_PATH: &str = ".yoi/tickets";
/// Concrete profile selector used by the initial Ticket role scaffold.
pub const TICKET_CONFIG_SCAFFOLD_PROFILE: &str = "builtin:default";
/// Return the explicit workspace Ticket config scaffold written by `yoi ticket init`.
///
@ -40,7 +38,7 @@ pub fn ticket_config_scaffold() -> String {
for role in TicketRole::ALL {
out.push_str(&format!(
"\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n",
TICKET_CONFIG_SCAFFOLD_PROFILE,
role.default_profile(),
role.default_workflow()
));
}
@ -234,6 +232,15 @@ impl TicketRole {
Self::Coder | Self::Reviewer => "multi-agent-workflow",
}
}
pub fn default_profile(self) -> &'static str {
match self {
Self::Intake => "builtin:intake",
Self::Orchestrator => "builtin:orchestrator",
Self::Coder => "builtin:coder",
Self::Reviewer => "builtin:reviewer",
}
}
}
impl fmt::Display for TicketRole {
@ -340,15 +347,15 @@ impl Default for TicketRoleProfiles {
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum TicketRoleLaunchConfigError {
#[error(
"Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with `profile = \"builtin:default\"` or another executable concrete profile selector"
"Ticket role `{role}` is not launch-configured; add `[roles.{role}]` with the role builtin profile or another executable concrete profile selector"
)]
MissingRoleTable { role: TicketRole },
#[error(
"Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to `builtin:default` or another executable concrete profile selector"
"Ticket role `{role}` has no launch profile; set `[roles.{role}].profile` to the role builtin profile or another executable concrete profile selector"
)]
MissingProfile { role: TicketRole },
#[error(
"Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as `builtin:default` or a project/user profile"
"Ticket role `{role}` uses `profile = \"inherit\"`; top-level Ticket role launch requires an explicit executable profile selector such as the role builtin profile or a project/user profile"
)]
InheritProfile { role: TicketRole },
}
@ -752,7 +759,8 @@ workflow = "multi-agent-workflow"
for role in TicketRole::ALL {
assert!(scaffold.contains(&format!("[roles.{role}]")));
assert!(scaffold.contains(&format!(
"[roles.{role}]\nprofile = \"builtin:default\"\nworkflow = \"{}\"",
"[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"",
role.default_profile(),
role.default_workflow()
)));
}
@ -767,7 +775,7 @@ workflow = "multi-agent-workflow"
assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets"));
for role in TicketRole::ALL {
let role_config = config.role_launch_config(role).unwrap();
assert_eq!(role_config.profile.as_str(), "builtin:default");
assert_eq!(role_config.profile.as_str(), role.default_profile());
assert_eq!(role_config.workflow.as_str(), role.default_workflow());
}
}

View File

@ -1,10 +1,9 @@
local base = require("_base")
local scope = require("yoi.scope")
return base {
return yoi.profile {
slug = "coder",
description = "Coder role profile: GPT-5.5 with bundled default behavior",
model_ref = "codex-oauth/gpt-5.5",
description = "Coder role profile with bundled reusable policy",
scope = yoi.scope.workspace_write(),
feature = {
task = { enabled = false },
memory = { enabled = true },
@ -13,6 +12,4 @@ return base {
ticket = { enabled = false, access = "lifecycle" },
ticket_orchestration = { enabled = false },
},
language = "Japanese",
scope = scope.workspace_write(),
}

View File

@ -1,9 +1,9 @@
local base = require("_base")
return base {
return yoi.profile {
slug = "companion",
description = "Companion role profile: GPT-5.5 with bundled default behavior",
model_ref = "codex-oauth/gpt-5.5",
description = "Companion role profile with bundled reusable policy",
scope = yoi.scope.workspace_read(),
feature = {
task = { enabled = false },
memory = { enabled = true },
@ -12,5 +12,4 @@ return base {
ticket = { enabled = false, access = "lifecycle" },
ticket_orchestration = { enabled = false },
},
language = "Japanese",
}

View File

@ -1,9 +1,9 @@
local base = require("_base")
return base {
return yoi.profile {
slug = "intake",
description = "Intake role profile: GPT-5.5 with bundled default behavior",
model_ref = "codex-oauth/gpt-5.5",
description = "Intake role profile with bundled reusable policy",
scope = yoi.scope.workspace_read(),
feature = {
task = { enabled = false },
memory = { enabled = true },
@ -12,5 +12,4 @@ return base {
ticket = { enabled = true, access = "lifecycle" },
ticket_orchestration = { enabled = false },
},
language = "Japanese",
}

View File

@ -1,10 +1,9 @@
local base = require("_base")
local scope = require("yoi.scope")
return base {
return yoi.profile {
slug = "orchestrator",
description = "Orchestrator role profile: GPT-5.5 with bundled default behavior",
delegation_scope = scope.workspace_write(),
description = "Orchestrator role profile with bundled reusable policy",
scope = yoi.scope.workspace_read(),
feature = {
task = { enabled = false },
memory = { enabled = true },
@ -13,6 +12,6 @@ return base {
ticket = { enabled = true, access = "lifecycle" },
ticket_orchestration = { enabled = true },
},
model_ref = "codex-oauth/gpt-5.5",
language = "Japanese",
delegation_scope = yoi.scope.workspace_write(),
}

View File

@ -1,9 +1,9 @@
local base = require("_base")
return base {
return yoi.profile {
slug = "reviewer",
description = "Reviewer role profile: GPT-5.5 with bundled default behavior",
model_ref = "codex-oauth/gpt-5.5",
description = "Reviewer role profile with bundled reusable policy",
scope = yoi.scope.workspace_read(),
feature = {
task = { enabled = false },
memory = { enabled = true },
@ -12,5 +12,4 @@ return base {
ticket = { enabled = false, access = "lifecycle" },
ticket_orchestration = { enabled = false },
},
language = "Japanese",
}