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
11 changes: 11 additions & 0 deletions .apm/architecture/owners/core-runtime.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@
"owner": "core/project_name.py (resolve_bootstrap_project_name)",
"selectors": ["src/apm_cli/core/project_name.py"],
"guards": ["registry-delegation-bootstrap-project-name"]
},
{
"id": "user-invocable-resolution",
"decision": "Agent user-invocable resolution from frontmatter, including the visibility: internal alias",
"owner": "primitives/models.py (resolve_user_invocable)",
"selectors": [
"src/apm_cli/primitives/models.py",
"src/apm_cli/primitives/parser.py",
"src/apm_cli/integration/agent_integrator.py"
],
"guards": ["registry-delegation-user-invocable-resolution"]
}
]
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Agent primitives (`*.agent.md`) accept an optional `user-invocable: false` frontmatter field (default `true`; `visibility: internal` is an accepted alias). APM preserves the metadata so the agent is intended to be reachable programmatically only (for example via `handoffs:` or subagent calls); picker exclusion and that reachability take effect only when the receiving harness supports them. Verbatim-copy targets preserve the field; Codex and Kiro warn instead of silently dropping it. -- by @richard-fowles-epam (#3132)
- New `grok-bot` install target deploys skill packages to `agent-data/workflows/<skill-name>/` for Grok Bot at project and user (`--global`) scope; the `grok-bot` target accepts packages declaring `agent-skills`, and an explicit `--target` fails only when no dependency in the install has a compatible target or a package named on the command line (and its whole subtree) has none; other filtered dependencies warn. (#3106)

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ for...
| `tools` | optional | Whitelist of tools the persona may call |
| `color` | optional | Display color for harnesses that render it (Copilot, Claude, OpenCode). OpenCode requires a `#rgb`/`#rrggbb` hex literal or one of its theme names; see "Common pitfalls" below |
| `handoffs` | optional | List of agent names (or VS Code structured handoff objects) this agent can hand off to |
| `user-invocable` | optional | `false` records that the agent is intended to be reachable programmatically only (for example via `handoffs:` or a subagent call). Defaults to `true`. `visibility: internal` is an accepted alias. APM preserves the metadata; picker exclusion applies only if the receiving harness supports it |

Set `user-invocable: false` (or the `visibility: internal` alias) when
the agent is intended to be reachable programmatically only, for example
via another agent's `handoffs:` or a subagent call, and not offered in a
user-facing picker. APM only preserves that metadata. Exclusion from the
agent picker, and programmatic-only reachability, take effect only when
the receiving harness supports them. APM does not filter the picker
itself. Verbatim targets (Copilot, Claude, Grok Build, Cursor, OpenCode)
carry the field through unchanged. Codex and Kiro cannot represent it,
so `apm install` emits a warning naming the agent instead of silently
dropping it -- the APM source agent stays the authority for the preserved
metadata.

`model` and `tools` reach Copilot, Claude, Grok Build, Cursor, and OpenCode
verbatim. Kiro receives `description`, `model`, and `tools` only;
Expand Down
12 changes: 12 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/package-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,18 @@ instructions: |
---
```

Optional `user-invocable: false` (boolean, default `true`) records that the
agent is intended to be reachable programmatically only, for example via
another agent's `handoffs:` or a subagent call. APM only preserves the
metadata. Exclusion from the user-facing agent picker, and programmatic-only
reachability, take effect only when the receiving harness supports them.
APM does not filter the picker itself. `visibility: internal` is an accepted
alias (the canonical field wins if both are set). Verbatim targets (Copilot,
Claude, Cursor, OpenCode, Grok Build) carry the field through unchanged.
Codex and Kiro cannot represent it, so APM emits an install-time warning
naming the agent instead of silently dropping it -- the APM source stays the
authority for the preserved metadata.

#### Codex target: native model settings

Codex agents deploy to `.codex/agents/<name>.toml`. APM writes `name`,
Expand Down
77 changes: 77 additions & 0 deletions scripts/architecture_linter/checks/registry_owner_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,23 @@
_RESOLVER_ALT = r"_?resolve_bootstrap_project_name"


_USER_INVOCABLE_OWNER = "src/apm_cli/primitives/models.py"


_USER_INVOCABLE_CONSUMERS: tuple[str, ...] = (
"src/apm_cli/primitives/parser.py",
"src/apm_cli/integration/agent_integrator.py",
)


_USER_INVOCABLE_OWNED_DEFS: tuple[str, ...] = ("resolve_user_invocable",)


_USER_INVOCABLE_REDERIVE_PATTERN = (
r'\.get\(\s*["\']user-invocable["\']\s*\)|\.get\(\s*["\']visibility["\']\s*\)'
)


def _count_fixed_lines(facts: FileFacts, needle: str) -> int:
"""Count lexical lines containing `needle` (mirrors ``grep -Fc``)."""
return sum(1 for line in facts.lines if needle in line)
Expand Down Expand Up @@ -581,6 +598,56 @@ def _check_bootstrap_project_name(provider: FactsProvider) -> Iterable[Violation
return findings


def _check_user_invocable_resolution(provider: FactsProvider) -> Iterable[Violation]:
"""Agent user-invocability must route through ``resolve_user_invocable``.

The parser and agent integrator must call the single interpretation
authority in ``primitives/models.py`` instead of re-deriving the boolean
from the ``user-invocable`` field or its ``visibility: internal`` alias.
"""
rule_id = "registry_delegation.user_invocable_resolution"
consumers = (_USER_INVOCABLE_OWNER, *_USER_INVOCABLE_CONSUMERS)
facts_by_path, failures = _read_required(provider, rule_id, consumers)
if failures:
return failures

findings: list[Violation] = []
for name in _USER_INVOCABLE_OWNED_DEFS:
definers, def_failures = _defining_files(provider, rule_id, name, kinds=("function",))
if def_failures:
findings.extend(def_failures)
continue
if definers != frozenset({_USER_INVOCABLE_OWNER}):
findings.append(
violation(
rule_id,
_USER_INVOCABLE_OWNER,
f"{name} must be defined only by primitives/models.py",
)
)

rederive_pattern = re.compile(_USER_INVOCABLE_REDERIVE_PATTERN)
for consumer in _USER_INVOCABLE_CONSUMERS:
facts = facts_by_path[consumer]
if not _has_fixed(facts, "resolve_user_invocable("):
findings.append(
violation(
rule_id,
consumer,
"consumer must call resolve_user_invocable() instead of re-deriving it",
)
)
if _has_regex(facts, rederive_pattern):
findings.append(
violation(
rule_id,
consumer,
"consumer must not re-derive user-invocable/visibility locally",
)
)
return findings


def _is_name(node: ast.AST | None, name: str) -> bool:
return isinstance(node, ast.Name) and node.id == name

Expand Down Expand Up @@ -690,6 +757,16 @@ def _has_named_assignment(
description="Bootstrap project names must route through core/project_name.py.",
check=_check_bootstrap_project_name,
),
Rule(
id="registry_delegation.user_invocable_resolution",
group=GROUP,
guard_ids=("registry-delegation-user-invocable-resolution",),
description=(
"Agent user-invocability must route through "
"primitives/models.py (resolve_user_invocable)."
),
check=_check_user_invocable_resolution,
),
)


Expand Down
63 changes: 62 additions & 1 deletion src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult
from apm_cli.integration.opencode_frontmatter import validate_opencode_frontmatter
from apm_cli.primitives.models import resolve_user_invocable
from apm_cli.utils.atomic_io import normalize_crlf_to_lf, write_text_lf
from apm_cli.utils.diagnostics import printable_ascii_text
from apm_cli.utils.path_security import PathTraversalError, ensure_path_within
Expand Down Expand Up @@ -428,7 +429,14 @@ def _warn_opencode_frontmatter(
# the model-settings extraction loop and the dropped-fields exclusion set
# below, so adding a third recognized field only requires one edit here.
_CODEX_MODEL_FIELDS = ("model", "model_reasoning_effort")
_CODEX_KNOWN_FIELDS = frozenset({"name", "description", "tools", *_CODEX_MODEL_FIELDS})
# 'user-invocable' and its 'visibility' alias are recognized-but-lossy:
# they get their own dedicated diagnostic via _warn_user_invocable_dropped
# (with the actionable 'cannot be guaranteed' hint), so they must be
# excluded here to avoid a second, generic dropped-fields warning for the
# same key.
_CODEX_KNOWN_FIELDS = frozenset(
{"name", "description", "tools", "user-invocable", "visibility", *_CODEX_MODEL_FIELDS}
)

@staticmethod
def _display_dropped_field_key(field: object) -> str:
Expand Down Expand Up @@ -492,6 +500,52 @@ def _warn_codex_tools_dropped(
),
)

@staticmethod
def _warn_user_invocable_dropped(
fm,
source: Path,
target_name: str,
diagnostics: DiagnosticCollector | None,
package_name: str,
) -> None:
"""Warn that a target renderer cannot carry programmatic-only status.

No-ops when diagnostics are unavailable, the frontmatter is not a
mapping, or the agent resolves as user-invocable (the default). The
single interpretation authority is resolve_user_invocable(), shared
with the parser, so the model and the integrator agree on meaning:
the source may have set 'user-invocable: false' directly or only the
'visibility: internal' alias.

The emitted diagnostic is a warning (lossy compilation), not an error:
the agent is still deployed, but this renderer cannot encode the
source's programmatic-only intent, so that semantic cannot be
guaranteed after the render. The APM source frontmatter remains the
authority; operators who must keep the agent out of a user-facing
picker should not generate/deploy it for this target, or should
prefer a target that preserves 'user-invocable'.
"""
if diagnostics is None:
return
if not isinstance(fm, dict):
return
if resolve_user_invocable(fm):
return
diagnostics.lossy_agent_compilation(
message=(
f"{target_name} agent {printable_ascii_text(source.name)}: user-invocability "
"(resolved from 'user-invocable' or the 'visibility: internal' alias) cannot be "
"represented in this target format; programmatic-only semantics cannot be "
"guaranteed after this lossy render."
),
package=printable_ascii_text(package_name),
detail=(
"The APM source agent remains the authority for user-invocability. Mitigation: "
"do not generate/deploy this agent for this target if it must stay out of a "
"user-facing agent picker, or prefer a target that preserves 'user-invocable'."
),
)

@staticmethod
def _write_codex_agent(
source: Path,
Expand Down Expand Up @@ -593,6 +647,9 @@ def _write_codex_agent(
source,
package_name,
)
AgentIntegrator._warn_user_invocable_dropped(
fm, source, "Codex", diagnostics, package_name
)
except yaml.YAMLError:
AgentIntegrator._warn_codex_unverified_scope(
diagnostics,
Expand Down Expand Up @@ -748,6 +805,10 @@ def _preflight_render_kiro_agent(
out_fm_ordered["tools"] = out_fm["tools"]
out_fm = out_fm_ordered

AgentIntegrator._warn_user_invocable_dropped(
fm, source, "Kiro", diagnostics, package_name
)

if out_fm:
fm_text = yaml_to_str(out_fm)
rendered = f"---\n{fm_text}---\n{body}"
Expand Down
32 changes: 32 additions & 0 deletions src/apm_cli/primitives/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@
from typing import Union


def resolve_user_invocable(metadata: dict) -> bool:
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
"""Resolve whether an agent is user-invocable from its frontmatter.

Canonical field is ``user-invocable`` (boolean, default ``True``). The
``visibility: internal`` token is accepted as an alias for
``user-invocable: false``. The canonical field, when present, always wins
over the alias.

Coercion is defensive: YAML booleans pass through, and string tokens
(``"false"``/``"no"``/``"0"``/``"off"``, case-insensitive) resolve to
``False`` so a quoted value does not silently flip the meaning.

Args:
metadata (dict): Parsed frontmatter mapping.

Returns:
bool: ``False`` when the agent is marked programmatic-only, else ``True``.
"""
raw = metadata.get("user-invocable")
if raw is not None:
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
return raw.strip().lower() not in ("false", "no", "0", "off")
return bool(raw)
visibility = metadata.get("visibility")
return not (isinstance(visibility, str) and visibility.strip().lower() == "internal")


@dataclass
class Chatmode:
"""Represents a chatmode primitive."""
Expand All @@ -18,6 +47,9 @@ class Chatmode:
version: str | None = None
source: str | None = None # Source of primitive: "local" or "dependency:{package_name}"
handoffs: list[str | dict] | None = None # Agent handoff targets (optional, for .agent.md)
# Programmatic-only agents (user_invocable=False) are reachable only via another
# agent's handoffs block and are excluded from the user-facing agent picker.
user_invocable: bool = True

def validate(self) -> list[str]:
"""Validate chatmode structure.
Expand Down
10 changes: 9 additions & 1 deletion src/apm_cli/primitives/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from apm_cli.utils.patterns import normalize_apply_to
from apm_cli.utils.yaml_io import load_frontmatter

from .models import Chatmode, Context, Instruction, Primitive, Skill
from .models import (
Chatmode,
Context,
Instruction,
Primitive,
Skill,
resolve_user_invocable,
)


def parse_skill_file(file_path: str | Path, source: str = None) -> Skill: # noqa: RUF013
Expand Down Expand Up @@ -131,6 +138,7 @@ def _parse_chatmode(
version=metadata.get("version"),
source=source,
handoffs=handoffs,
user_invocable=resolve_user_invocable(metadata),
)


Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_architecture_owner_rule_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,14 @@ class MutationCase:
new="config_target = list(apm_package.targets)",
intent="A manifest consumer reads raw targets instead of canonical_targets.",
),
MutationCase(
guard_id="registry-delegation-user-invocable-resolution",
rule_id="registry_delegation.user_invocable_resolution",
path="src/apm_cli/primitives/models.py",
old="def resolve_user_invocable(metadata: dict) -> bool:",
new="def _resolve_user_invocable_removed(metadata: dict) -> bool:",
intent="The user-invocable owner loses its single interpretation entry point.",
),
MutationCase(
guard_id="transport-platform-ado-validation-bearer-fallback",
rule_id="transport-platform-host-credential-resolution",
Expand Down
Loading
Loading