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
25 changes: 25 additions & 0 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,19 @@ table solely to make a runtime installable.
The v0 runtime exposes integer extension API version `1` and accepts bounded
integer constraints such as `>=1,<2`; incompatible manifests fail closed.

`[[hook_adapters]]` lets an extension contribute provider ports to an existing
capability-owned hook point without adding provider branches to the capability
composition root or control-plane kernel. Discovery reads only installed
manifest declarations and admits a factory only when the extension is enabled,
doctor-ready, and authorized for every declared adapter permission. The
factory must return exactly the declared callable ports. Import, activation,
and factory failures become content-free optional-adapter failures; they do not
execute or replace kernel logic.

`phase = "capability_action"` is for an explicit typed action whose semantics
have already been decided by the Agent or caller. The adapter may bind and
settle provider evidence, but it must not infer the action from provider text.

```toml
schema_version = "loopx_extension_manifest_v0"
id = "loopx-lark"
Expand All @@ -781,6 +794,18 @@ real_world_anchor = "operator-facing Lark Base projection"
user_value = "Project public-safe LoopX status and todo rows into Lark."
entry_command = "loopx lark-kanban sync"
next_real_step = "Validate one explicitly enabled owner-approved sink."

[[hook_adapters]]
id = "lark-periodic-report-source"
capability_id = "periodic-report"
target_hook_id = "periodic_report.request"
phase = "capability_action"
factory = "loopx.extensions.lark.periodic_report_request:build_lark_periodic_report_hook_adapter"
required_permissions = ["lark.inbox.read", "lark.inbox.write"]
ports = [
"periodic_report.request.bind_source",
"periodic_report.request.settle_source",
]
```

The bundled OpenViking pilot uses `[[implements]]` instead:
Expand Down
61 changes: 61 additions & 0 deletions docs/reference/protocols/periodic-report-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,67 @@ An eligible decision may be embedded as `trigger_receipt` in a
participate in run identity, so a milestone update and a scheduled digest over
the same evidence window cannot collide.

### Agent-semantic Goal Channel requests

An Agent that has read and semantically interpreted one Goal Channel inbox item
may explicitly invoke the provider-neutral action:

```text
loopx periodic-report request \
--goal-id <goal-id> \
--agent-id <agent-id> \
--source-ref <opaque-provider-message-id> \
--execute
```

One complete active source adapter is selected automatically. If several
provider adapters are active, a new request must include
`--source-adapter-id <adapter-id>`. Replay of an already journaled request does
not require the selector when exactly one journal entry matches the source
reference; a source reference already owned by multiple providers requires an
explicit selector. The adapter id participates in request identity together
with Goal, Agent, and the provider-local source reference, so equal opaque ids
from different providers remain independent. Settlement reads the owner
`adapter_id` from that journal and resolves only the matching discovered
settler; discovery order cannot change ownership, and another provider is
never used as a fallback.

This action is the report-intent decision. Neither LoopX Core nor the provider
adapter classifies message strings, searches for report keywords, or scans the
inbox for candidate requests. The provider adapter receives the exact opaque
source reference selected by the Agent and verifies only source existence,
user authorship, provider-native addressing, the registered Goal/Agent
connection, the current provider target, and inbox identity. It returns a
content-free `periodic_report_source_binding_receipt_v0`; raw message content
never enters the capability intent or public output.

An executed request writes one replay-safe local-private
`periodic_report_request_journal_entry_v0`. The pending-intent projection reads
that typed journal and existing post-writeback intents only; it never calls a
provider reader. The request becomes an authorized `manual` trigger and then
reuses the existing editorial, generation bundle, Workspace projection,
publication candidate, and delivery-Todo pipeline. Repeating the same
Goal/Agent/adapter/source action returns the existing request and cannot create
another journal entry.

Source binding and settlement ports are dynamically discovered from enabled,
doctor-ready extension manifests through a `capability_action`
`[[hook_adapters]]` declaration. Generic discovery and the periodic-report
composition import no Lark implementation. The bundled Lark adapter is
therefore optional provider code, not a quota or decision-kernel branch.

The adapter ACKs the selected inbox item only after the `delivery_ready`
receipt and delivery Todo are durable. If ACK fails or the adapter is
temporarily unavailable, the typed request stays pending. A later
`consume-pending` call loads the durable receipt, retries settlement only, and
does not regenerate artifacts or add another delivery Todo. Exact ACK replay
is idempotent. A provider may instead return a typed `terminal_failure` when
the bound source is gone or its binding/receipt identity has drifted. LoopX
then records `settlement_failed`, leaves the provider source un-ACKed, and
removes that request from automatic retry projection. After repairing the
provider binding or retention problem, the operator must obtain a new provider
event and issue a new typed request with that event's opaque source reference.

### Post-writeback hook boundary

The optional automatic path uses the provider-neutral TypeScript
Expand Down
43 changes: 41 additions & 2 deletions loopx/capabilities/periodic_report/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ presentation, and destinations to profiles and adapters.

| Surface | Value |
| --- | --- |
| CLI | `loopx periodic-report inspect-profile --preset weekly`, custom `--profile-json <path>`, `evaluate-trigger`, `evaluate-runtime-trigger`, `compose-run`, and optional `archive-openviking` |
| CLI | `loopx periodic-report inspect-profile --preset weekly`, `request`, `consume-pending`, custom `--profile-json <path>`, `evaluate-trigger`, `evaluate-runtime-trigger`, `compose-run`, and optional `archive-openviking` |
| Protocol | [`periodic_report_v0`](../../../docs/reference/protocols/periodic-report-v0.md) |
| Smokes | `python3 examples/periodic-report-smoke.py`, `periodic-report-profile-smoke.py`, `periodic-report-html-smoke.py`, `periodic-report-bindings-smoke.py`, and `openviking-periodic-report-extension-smoke.py` |

Expand Down Expand Up @@ -56,6 +56,45 @@ research, operations, and other domains may supply peer source adapters when
their richer semantics are useful; none is required by the built-in weekly
profile.

## Request from a Goal Channel

After an Agent reads one addressed Goal Channel item and semantically decides
that the user is asking it for a report, it records that decision explicitly:

```bash
loopx periodic-report request \
--goal-id <goal-id> \
--agent-id <agent-id> \
--source-ref <message-id> \
--execute
```

When exactly one complete source adapter is active, the command selects it
automatically. With multiple active providers, the Agent selects the provider
explicitly with `--source-adapter-id <adapter-id>`. The journal retains that
adapter identity, and later settlement resolves only that owner regardless of
extension discovery order. A temporarily unavailable owner leaves the request
pending; LoopX never falls through to another provider.

The adapter id is part of the request idempotency namespace together with the
Goal, Agent, and provider-local source reference. Two providers may therefore
use the same opaque source reference without collapsing distinct requests.
Replay without a selector remains valid when exactly one matching journal entry
exists; if the same source reference is already owned by multiple providers,
the Agent must select the intended adapter explicitly.

There is no keyword or regular-expression classifier. The provider adapter
binds only the exact source selected by the Agent and checks authorship,
addressing, Goal/Agent connection, target, and inbox identity. A manifest-
discovered `capability_action` hook supplies the content-free bind and settle
ports, so this capability and quota import no Lark implementation.

The command persists a replay-safe typed request journal. `consume-pending`
uses the normal manual trigger, editorial, frozen artifact, Workspace, and
delivery-Todo pipeline. It acknowledges the provider source only after
`delivery_ready` durability; failed ACKs become settlement-only retries and do
not duplicate delivery work.

## Customize or schedule

The capability remains **inactive for background work and external writes by
Expand Down Expand Up @@ -402,7 +441,7 @@ external writes remain disabled by default:
},
"extension": {
"extension_id": "loopx-lark",
"extension_version": "1.5.0",
"extension_version": "1.6.0",
"protocol": "periodic_report_sink_v0"
}
}
Expand Down
76 changes: 73 additions & 3 deletions loopx/capabilities/periodic_report/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
from .runtime_producer import build_periodic_report_runtime_trigger_decision
from .triggers import build_periodic_report_trigger_decision
from .pending_intent import consume_pending_periodic_report_intent
from .request_action import (
discover_periodic_report_request_ports,
record_periodic_report_request,
)
from ...paths import resolve_runtime_root
from ...registry import read_json

Expand Down Expand Up @@ -98,6 +102,29 @@ def register_periodic_report_commands(
required=True,
help="Path to periodic_report_runtime_trigger_request_v0 JSON.",
)
request = commands.add_parser(
"request",
help=(
"Record an Agent-authorized typed report request for one exact "
"provider source item."
),
)
add_subcommand_format(request)
request.add_argument("--goal-id", required=True)
request.add_argument("--agent-id", required=True)
request.add_argument("--source-ref", required=True)
request.add_argument(
"--source-adapter-id",
help=(
"Select the manifest-discovered source adapter. Required when more "
"than one complete adapter is active."
),
)
request.add_argument("--execute", action="store_true")
request.add_argument(
"--extension-state-file",
help="Override local extension activation state for this action.",
)
consume_pending = commands.add_parser(
"consume-pending",
help=(
Expand All @@ -109,6 +136,10 @@ def register_periodic_report_commands(
consume_pending.add_argument("--goal-id", required=True)
consume_pending.add_argument("--agent-id", required=True)
consume_pending.add_argument("--execute", action="store_true")
consume_pending.add_argument(
"--extension-state-file",
help="Override local extension activation state for source settlement.",
)
configure_machine_defaults = commands.add_parser(
"configure-machine-defaults",
help="Preview or apply the runtime-root machine periodic-report policy.",
Expand Down Expand Up @@ -424,16 +455,55 @@ def handle_periodic_report_command(
strict=True,
),
)
elif args.periodic_report_command == "request":
registry = read_json(registry_path)
runtime_root = resolve_runtime_root(
registry, runtime_root_arg, registry_path=registry_path
)
ports = discover_periodic_report_request_ports(
registry_path=registry_path,
runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
extension_state_file=(
Path(args.extension_state_file).expanduser()
if args.extension_state_file
else None
),
)
payload = record_periodic_report_request(
registry_path=registry_path,
runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
source_ref=args.source_ref,
request_ports=ports,
source_adapter_id=args.source_adapter_id,
execute=bool(args.execute),
)
elif args.periodic_report_command == "consume-pending":
registry = read_json(registry_path)
payload = consume_pending_periodic_report_intent(
runtime_root = resolve_runtime_root(
registry, runtime_root_arg, registry_path=registry_path
)
ports = discover_periodic_report_request_ports(
registry_path=registry_path,
runtime_root=resolve_runtime_root(
registry, runtime_root_arg, registry_path=registry_path
runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
extension_state_file=(
Path(args.extension_state_file).expanduser()
if args.extension_state_file
else None
),
)
payload = consume_pending_periodic_report_intent(
registry_path=registry_path,
runtime_root=runtime_root,
goal_id=args.goal_id,
agent_id=args.agent_id,
execute=bool(args.execute),
provider_request_ports=ports,
)
elif args.periodic_report_command in {
"configure-machine-defaults",
Expand Down
Loading