Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
994f67b
docs: add workspace identity and storage flexibility design
Jun 9, 2026
690aa8e
docs: add workspace resolution and migration design
Jun 9, 2026
aafca35
docs(design): unify session layout — audits and --fresh follow worksp…
Jun 9, 2026
c0783b3
docs(plan): add B4/B5/E6 — unify audit and --fresh under workspace tree
Jun 9, 2026
4e752f9
feat(persistence): add workspaces_root() helper (D8)
Jun 9, 2026
e6ffe12
feat(persistence): add WorkspaceError + validate_slug (D3)
Jun 9, 2026
f5ec547
style(persistence): ruff-format validate_slug raise + add 64-char bou…
Jun 9, 2026
37b745f
feat(persistence): add slugify + derive_workspace_from_cwd (D4)
Jun 9, 2026
aa88067
refactor(persistence): make slugify module-private + strip trailing h…
Jun 9, 2026
0bda100
feat(persistence): add resolve_workspace top-level resolver (D2)
Jun 9, 2026
c2feaf8
fix(persistence): symmetric whitespace handling + invalid-env test
Jun 9, 2026
459cfad
feat(cli): add --workspace flag to run, thread to _TurnSpec (D1)
Jun 9, 2026
7dff12f
fix(cli): correct B1 test scaffolding + user-facing --workspace help
Jun 9, 2026
047d100
feat(runtime): resolve workspace, write workspace + project_slug to c…
Jun 9, 2026
3e7a552
fix(runtime): typed ctx in workspace tests + merge duplicate assertio…
Jun 10, 2026
bd22558
test(session-store): anchor per-workspace root layout (D8)
Jun 10, 2026
8e604a6
feat(runtime): move audit write path to per-workspace tree (workspace…
Jun 10, 2026
510111e
feat(runtime): scope --fresh cleanup to per-workspace session dir (wo…
Jun 10, 2026
0685e3b
refactor(runtime): drop redundant resolve_workspace re-import + symme…
Jun 10, 2026
60fc177
feat(spawn): propagate workspace to child coordinators verbatim (D7)
Jun 10, 2026
4582bc2
feat(migration): add migrate_legacy_sessions_if_needed (D9, flock-gua…
Jun 10, 2026
685b43a
feat(session-store): cross-workspace load fallback (D10)
Jun 10, 2026
b12ee53
refactor(session-store): hoist workspaces_root import + drop false cy…
Jun 10, 2026
0d784a2
feat(runtime): trigger legacy-sessions migration once per process (D9)
Jun 10, 2026
6fac1f8
test(integration): --workspace flag produces workspaces/<ws> layout (…
Jun 10, 2026
b71aa92
test(integration): AMPLIFIER_AGENT_WORKSPACE env var layout (D1, D2)
Jun 10, 2026
1ba54a1
test(integration): cwd-derived workspace is stable across invocations…
Jun 10, 2026
57a16f9
test(integration): legacy sessions migrated to _legacy on first boot …
Jun 10, 2026
1db26ea
test(integration): cross-workspace resume finds migrated session (D10)
Jun 10, 2026
a1b1e1d
test(integration): audit file lands under workspace tree after real t…
Jun 10, 2026
2f0d6d0
fix(tests): update broken audit-path tests + ruff-format test_spawn.py
Jun 10, 2026
b7b356b
feat(bundle): integrate hook-context-intelligence for local-only even…
Jun 10, 2026
fe21c33
chore(bundle): point hook-context-intelligence at upstream PR branch …
Jun 10, 2026
da7d9d8
chore(bundle): pin hook source to @3a94d0d (vendoring fix on PR #35)
Jun 10, 2026
45f005e
fix(integration): re-point hook source + literal base_path + project_…
Jun 10, 2026
15273a8
chore(bundle): expand TODO marker for upstream-pr-35 fork pin
Jun 10, 2026
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
257 changes: 257 additions & 0 deletions docs/designs/2026-06-09-workspace-identity-and-storage-flexibility.md

Large diffs are not rendered by default.

374 changes: 374 additions & 0 deletions docs/designs/2026-06-09-workspace-resolution-and-migration.md

Large diffs are not rendered by default.

2,491 changes: 2,491 additions & 0 deletions docs/plans/2026-06-09-workspace-implementation.md

Large diffs are not rendered by default.

35 changes: 31 additions & 4 deletions src/amplifier_agent_cli/modes/single_turn.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from amplifier_agent_lib.bundle.cache import load_and_prepare_cached
from amplifier_agent_lib.config import ConfigError, load_config
from amplifier_agent_lib.engine import Engine
from amplifier_agent_lib.persistence import WorkspaceError, resolve_workspace
from amplifier_agent_lib.protocol import PROTOCOL_VERSION, server_default_capabilities
from amplifier_agent_lib.protocol.errors import AaaError
from amplifier_agent_lib.protocol_points.defaults_cli import CliApprovalSystem, CliDisplaySystem
Expand Down Expand Up @@ -255,6 +256,7 @@ def _write_audit(
ended_at: str,
argv: list[str],
protocol_version: str,
workspace: str,
) -> None:
"""SC-H — write per-turn audit digest. Secrets are sha256'd, never literal.

Expand All @@ -266,11 +268,11 @@ def _write_audit(
former mcpConfigPathDigest field was dropped entirely because no
argv-supplied path remains to digest.
"""
from amplifier_agent_lib.persistence import session_state_dir
from amplifier_agent_lib.persistence import workspaces_root

if not session_id:
return # No session id ⇒ no audit (matches anonymous CLI use).
audits_dir = session_state_dir(session_id) / "audits"
audits_dir = workspaces_root() / workspace / "sessions" / session_id / "audits"
audits_dir.mkdir(parents=True, exist_ok=True)
audit = {
"argvDigest": _sha256(" ".join(argv)),
Expand Down Expand Up @@ -403,6 +405,7 @@ class _TurnSpec:
provider: str # detected provider short-name (e.g. 'anthropic')
allow_protocol_skew: bool = False
host_config: dict | None = None
workspace: str | None = None


# ---------------------------------------------------------------------------
Expand All @@ -424,9 +427,12 @@ async def _execute_turn(spec: _TurnSpec) -> dict[str, Any]:
if spec.fresh and spec.session_id:
import shutil

from amplifier_agent_lib.persistence import session_state_dir
from amplifier_agent_lib.persistence import workspaces_root

state_dir = session_state_dir(spec.session_id)
# WorkspaceError is caught upstream in run() via _emit_argv_envelope;
# by the time we reach _execute_turn, spec.workspace is already validated.
resolved_workspace = resolve_workspace(spec.workspace, os.environ, Path(spec.cwd) if spec.cwd else Path.cwd())
state_dir = workspaces_root() / resolved_workspace / "sessions" / spec.session_id
if state_dir.exists():
shutil.rmtree(state_dir, ignore_errors=True)

Expand All @@ -435,6 +441,7 @@ async def _execute_turn(spec: _TurnSpec) -> dict[str, Any]:
cwd=spec.cwd,
is_resumed=spec.resume and not spec.fresh,
host_config=spec.host_config,
workspace=spec.workspace,
)
engine = Engine(
turn_handler=handler,
Expand Down Expand Up @@ -501,6 +508,11 @@ async def _execute_turn(spec: _TurnSpec) -> dict[str, Any]:
default=None,
help="Wrapper's pinned protocol version; engine self-validates.",
)
@click.option(
"--workspace",
default=None,
help="Workspace name for isolating session state by project (defaults to current directory).",
)
def run(
prompt: str | None,
session_id: str | None,
Expand All @@ -517,6 +529,7 @@ def run(
quiet: bool,
output_mode: str,
protocol_version_arg: str | None,
workspace: str | None,
) -> None:
"""Run the agent in single-turn mode (Mode A).

Expand Down Expand Up @@ -623,8 +636,19 @@ def run(
provider=provider_name,
allow_protocol_skew=bool((host_config or {}).get("allowProtocolSkew", False)),
host_config=host_config,
workspace=workspace,
)

# (6b) Resolve workspace once for CLI-layer state paths (audit trail, --fresh
# cleanup). Same (argv, env, cwd) inputs as _runtime's resolution (D2/D4),
# so the slug is byte-identical to the handler's. Fail fast on an invalid
# --workspace before booting (workspace I8).
try:
resolved_workspace = resolve_workspace(spec.workspace, os.environ, Path(spec.cwd) if spec.cwd else Path.cwd())
except WorkspaceError as exc:
_emit_argv_envelope("argv_workspace_invalid", str(exc), exit_code=2)
return # unreachable; _emit_argv_envelope calls sys.exit

# (7) Run with error handling.
# Capture the real stdout FD before any redirection so the final envelope
# emission (and only that) writes to it. CR-B / §4.0 stdout discipline:
Expand Down Expand Up @@ -667,6 +691,7 @@ def run(
ended_at=datetime.now(UTC).isoformat(),
argv=sys.argv,
protocol_version=PROTOCOL_VERSION,
workspace=resolved_workspace,
)
sys.exit(exit_code)
except Exception as exc:
Expand All @@ -690,6 +715,7 @@ def run(
ended_at=datetime.now(UTC).isoformat(),
argv=sys.argv,
protocol_version=PROTOCOL_VERSION,
workspace=resolved_workspace,
)
sys.exit(1)
duration_ms = int((time.monotonic() - started) * 1000)
Expand All @@ -715,4 +741,5 @@ def run(
ended_at=datetime.now(UTC).isoformat(),
argv=sys.argv,
protocol_version=PROTOCOL_VERSION,
workspace=resolved_workspace,
)
75 changes: 74 additions & 1 deletion src/amplifier_agent_lib/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from amplifier_agent_lib.config import merge_config
from amplifier_agent_lib.engine import TurnContext, TurnHandler
from amplifier_agent_lib.incremental_save import IncrementalSaveHook
from amplifier_agent_lib.migration import migrate_legacy_sessions_if_needed
from amplifier_agent_lib.persistence import state_root
from amplifier_agent_lib.session_store import SessionStore
from amplifier_agent_lib.wire_approval_provider import WireApprovalProvider
Expand All @@ -33,6 +34,10 @@

logger = logging.getLogger(__name__)

# Process-level guard: the legacy-sessions migration runs at most once per
# process (D9), on the first turn handled.
_MIGRATION_RAN = False


def _repair_loaded_transcript_if_needed(
loaded_transcript: list[dict],
Expand Down Expand Up @@ -131,6 +136,7 @@ def make_turn_handler(
cwd: str | None,
is_resumed: bool,
host_config: dict[str, Any] | None = None,
workspace: str | None = None,
) -> TurnHandler:
"""Return a TurnHandler closed over the loaded PreparedBundle.

Expand Down Expand Up @@ -158,17 +164,39 @@ def make_turn_handler(
``amplifier_module_tool_mcp/config.py``). Hosts that prefer can set
``AMPLIFIER_MCP_CONFIG`` directly in the engine's process environment
instead; ``tool-mcp`` reads it natively.
workspace:
Optional workspace slug from the CLI ``--workspace`` flag (D1).
Resolved once at handler-creation time via
``persistence.resolve_workspace`` (argv > env > cwd, D2). The
resolved slug is written to ``coordinator.config`` as both
``"workspace"`` (AAA-canonical) and ``"project_slug"``
(ecosystem-canonical alias, D5) and determines the
``SessionStore`` root (D8).

Returns
-------
TurnHandler
Async callable that accepts a TurnContext and returns a reply string.
"""
from amplifier_agent_lib.bundle.hook_streaming import mount as mount_streaming_hook
from amplifier_agent_lib.persistence import resolve_workspace
from amplifier_agent_lib.spawn import hydrate_agent_overlay, spawn_sub_session

resolved_cwd: Path | None = Path(cwd).resolve() if cwd else None

# Resolve the workspace identity once (cold path). argv > env > cwd (D2).
# The resolved slug buckets all session state for this handler's turns and
# is written to coordinator.config inside the handler (D5).
resolved_workspace = resolve_workspace(
argv_workspace=workspace,
env=os.environ,
cwd=resolved_cwd if resolved_cwd is not None else Path.cwd(),
)
# D8: workspace root is state_root()/workspaces/<slug>. Using the module-
# level state_root() name (not workspaces_root() from persistence) so that
# test-time monkeypatching of state_root propagates through correctly.
workspace_root = state_root() / "workspaces" / resolved_workspace

# D4: host_config.mcp.configPath → AMPLIFIER_MCP_CONFIG env var.
# configPath is an engine-level convenience key, not a tool-mcp config key.
# tool-mcp resolves it from its own AMPLIFIER_MCP_CONFIG priority chain.
Expand Down Expand Up @@ -209,6 +237,32 @@ def make_turn_handler(
if mid and mid in merged_modules:
entry["config"] = merged_modules[mid]

# Fix C: Pre-seed project_slug (and workspace alias) into hook-context-
# intelligence's own module config so the hook resolves the correct
# workspace slug when session:start fires INSIDE create_session()
# (before the post-create_session coordinator.config writes land).
#
# Background: the hook's resolution chain is:
# config['project_slug'] ← hook's own module config (checked first)
# → coordinator.config['project_slug'] ← written by D5 AFTER create_session
# → session.working_dir capability (slugified)
# → 'default'
#
# create_session() calls session.initialize() internally, which mounts the
# hook AND fires session:start before returning. At that point
# coordinator.config['project_slug'] is still unset, so the hook falls
# through to the working_dir slug (the bundle install dir path), producing
# the wrong bucket. Injecting into the hook's own config (level 1)
# ensures the hook has the right value from the very first event, without
# requiring any change to the foundation's create_session() API.
for entry in mount_plan.get("hooks") or []:
if entry.get("module") == "hook-context-intelligence":
hook_cfg = dict(entry.get("config") or {})
hook_cfg["project_slug"] = resolved_workspace
hook_cfg["workspace"] = resolved_workspace
entry["config"] = hook_cfg
break

# Pre-hydrate agent overlays from the vendored agent markdown files.
# This is done once at handler-creation time (cold path) so each turn
# pays no I/O cost. The overlay dicts are closed over in the handler.
Expand All @@ -225,10 +279,22 @@ def make_turn_handler(
async def handler(ctx: TurnContext) -> str:
session_id = ctx.session_id if ctx.session_id else None

global _MIGRATION_RAN
if not _MIGRATION_RAN:
_MIGRATION_RAN = True
try:
migrate_legacy_sessions_if_needed()
except Exception:
# A migration failure must not block the turn. Cross-workspace
# resume (D10) tolerates partially-migrated state; the next
# boot retries. Log and continue.
logger.exception("legacy-sessions migration failed; continuing")

# Build the SessionStore once per turn. If the session is being
# resumed, attempt to load a previously persisted transcript so it
# can be replayed into the new session via ``context.set_messages``.
store = SessionStore(state_root())
# D8: bucket all session state under the per-workspace root.
store = SessionStore(workspace_root)
loaded_transcript: list[dict] | None = None
if session_id and is_resumed:
loaded = store.load(session_id)
Expand All @@ -254,6 +320,13 @@ async def handler(ctx: TurnContext) -> str:
is_resumed=is_resumed,
)

# D5: write workspace identity to coordinator.config. project_slug is
# the ecosystem-canonical alias every existing hook reads; workspace is
# the AAA-canonical name. Written as aliases (I4) until the ecosystem
# aligns on one.
session.coordinator.config["workspace"] = resolved_workspace
session.coordinator.config["project_slug"] = resolved_workspace

# Wire display and approval into the coordinator so hook events can
# flow back to the client. Per SC-1, set default event fields so
# every kernel event carries session_id and turn_id automatically.
Expand Down
60 changes: 58 additions & 2 deletions src/amplifier_agent_lib/bundle/bundle.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
bundle:
name: amplifier-agent-builtin
version: 1.2.1
version: 1.3.0
description: >
Vendored opinionated manifest for the amplifier-agent CLI. Aligned with the
upstream build-up-foundation experimental bundle
Expand All @@ -15,7 +15,10 @@ bundle:
first invocation. The prepared result is cached to
$XDG_CACHE_HOME/amplifier-agent/prepared/<aaa_version>/<sha256(bundle.md)>/.
Editing this file changes the cache key (sha256) and self-invalidates
the warm pickle.
the warm pickle. AAA-specific additions beyond upstream parity:
hook-context-intelligence for local-only event logging under the
workspace tree (see docs/designs/2026-06-09-workspace-resolution-and-migration.md
invariant I8 — unified per-session layout).

# Engine-level default provider routing. Read by the host/CLI config layer
# to seed the default provider selection before any host-supplied override
Expand Down Expand Up @@ -144,6 +147,59 @@ hooks:
initial_trigger_turn: 2
update_interval_turns: 5

# === Observability hooks (local-only event logging) ===
# Captures kernel + delegate lifecycle events to JSONL alongside transcripts
# and audits in the workspace tree (invariant I8 — unified per-session
# layout). No remote dispatch — server URL and API key are intentionally
# not set, so the hook operates in local-logging mode. If/when AAA exposes
# a server-config layer, dispatch can be lit up via
# AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL + ..._API_KEY env vars without
# a bundle.md change.
- module: hook-context-intelligence
# TODO(upstream-pr-35): re-point to a stable upstream tag when merged
# ──────────────────────────────────────────────────────────────────
# This source is pinned to a fork branch (manojp99/...@proposal/...)
# while upstream PR #35 awaits maintainer review.
#
# PR: https://github.com/microsoft/amplifier-bundle-context-intelligence/pull/35
# Issue: https://github.com/microsoft-amplifier/amplifier-support/issues/269
#
# Why the pin: v0.1.1 declares amplifier-bundle-context-intelligence as a
# runtime dependency that's only resolvable via [tool.uv.sources] path
# mapping, which AAA's foundation activator strips via --no-sources
# (documented at amplifier_foundation/modules/activator.py:471). The hook
# fails to mount in AAA without the upstream fix.
#
# Fork branch contains two commits:
# 45b038f - remove the bogus runtime dep (fixes install layer)
# 3a94d0d - vendor the 4 needed symbols (fixes mount layer)
#
# When upstream merges PR #35 (with whatever maintainer adjustments):
# 1. Re-point this source URL to microsoft/...@<merged-sha-or-tag>
# 2. Remove this TODO block
# 3. Bump bundle version (1.3.0 → 1.4.0) if the merged shape differs
# 4. Re-run the DTU verification (4 scenarios from PR description)
source: git+https://github.com/manojp99/amplifier-bundle-context-intelligence@proposal/decouple-hook-from-parent-bundle#subdirectory=modules/hook-context-intelligence
config:
log_level: INFO
# base_path points at the default XDG_STATE_HOME location for AAA's
# workspace tree so context-intelligence events land alongside
# transcripts and audits (I8).
# Hook computes: <base_path>/<project_slug>/sessions/<id>/context-intelligence/
# project_slug is seeded from coordinator.config["project_slug"] (D5),
# so the final on-disk path is:
# ~/.local/state/amplifier-agent/workspaces/<workspace>/sessions/<id>/context-intelligence/
# NOTE: If XDG_STATE_HOME is overridden, AAA's transcripts/audits
# relocate but this hook's events do not — a portable fix needs upstream
# expandvars support in the hook's config_resolver.
base_path: "~/.local/state/amplifier-agent/workspaces"
additional_events:
- delegate:agent_spawned
- delegate:agent_resumed
- delegate:agent_completed
- delegate:agent_cancelled
- delegate:error

# The four self-sufficient sub-session agents this bundle ships.
# Definitions are vendored at src/amplifier_agent_lib/bundle/agents/<name>.md;
# the loader hydrates them at compose time into overlay-shaped dicts that the
Expand Down
Loading
Loading