Skip to content

feat(apl): address one content part of a message instead of the whole thing #76

Description

@terylt

Description

A CMF Message is a list of typed ContentParts. An assistant turn is thinking plus text plus two tool calls; a rendered prompt is a list of messages; an A2A artifact is a list of parts. APL addresses none of that individually. It gets one args value and one result value per message, produced by a projection that reads the first tool call, the first prompt request, or the first tool result and otherwise falls back to the concatenated text (crates/ppe-apl-runtime/src/message_projection.rs).

So there is no way to say "redact the text part and leave the thinking part alone", or "this rule applies to the second tool call", or "scan each message of the rendered prompt". A pipeline that writes back gets the first text part or an appended one, whatever the message actually held.

It is already half built

crates/ppe-core/src/cmf/view.rs defines MessageView, a zero-copy read-only view over one content part, with kind(), role(), action(), is_pre() / is_post() read off the hook metadata registry, name(), uri(), args(), get_arg(), and content(). ViewKind and ViewAction are defined with the predicates a matcher would want (is_tool, is_resource, is_prompt, is_media, is_text). Message::iter_views() produces them.

Nothing outside view.rs calls it. iter_views has no caller in the workspace except its own unit tests, and no APL crate mentions MessageView.

Worth noting how much this would buy directly: MessageView::content() already reads Resource.content and PromptResult.content, which are exactly the two values message_projection.rs cannot see today. The read side of the resource and prompt gap is written and unused.

The intended shape is recorded too. The header of crates/ppe-apl-runtime/src/lib.rs says the v0 simplification is one view per message, that it holds only because v0 handles request-side single-part flows, and that when response-side handling lands the crate needs routes as a list with a match: block filtering on MessageView attributes. Half of that has already happened on its own: PolicyConfig.routes is a Vec<RouteEntry> today.

There are likely many ways to do this and we should start out with a design document first. One option is a match: block and the runtime that honors it.

Sketch

A route gains an optional matcher over views:

routes:
  - llm: "claude-*"
    match:
      kind: thinking
    result:
      "": "scan(injection)"
  - llm: "claude-*"
    match:
      kind: text
    result:
      "": "str | redact(pii)"

The runtime iterates the message's views, selects those the matcher accepts, and runs the route's pipelines per matched view rather than once per message, with write-back going to that view's part.

The questions that need answering before any of that is code:

  • What is a route now? One route per message is what the annotation table assumes today: one handler per (entity_type, entity_name, scope, hook). Per-view dispatch means one handler running N times, or N handlers, and those are different answers for pending elicitations, for taints, and for a deny halting the rest.
  • What does a deny on one view mean? The whole message is denied, presumably, since half a message cannot be forwarded. Worth stating rather than discovering.
  • How do two matching routes compose? The specificity ladder scores selectors, not matchers. Two routes matching different views of one message both apply; two matching the same view need an order.
  • Does the bag gain view attributes? view.kind, view.role, view.action, view.name would make a matcher expressible as an ordinary predicate rather than a new config shape, which is the cheaper design if it works.
  • What addresses "the second tool call"? Index-based addressing is brittle across a rewrite. Matching on name is probably the answer, with the acceptance that two calls to the same tool in one turn are then indistinguishable.

Acceptance criteria

  • A route can scope its args: / result: pipelines to a subset of a message's content parts.
  • Write-back lands on the matched part. No appended text part on a message that carried a structured part.
  • The multi-view case is covered for each shape that needs it: an assistant turn with thinking plus text plus tool calls, a PromptResult with several messages, a message with several resource parts.
  • The composition rules above are documented, in particular what a deny on one view does to the message.
  • iter_views gains a production caller, or it is deleted. An unused view abstraction that the projection could have used is worse than none.
  • e2e tests assert on a deny and on a field-level rewrite, not on an allow.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions