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
32 changes: 29 additions & 3 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2446,10 +2446,18 @@ async def _handle_external_event(self, payload: Dict) -> None:
integration_type = payload.get("integrationType", "").lower()
is_self_message = payload.get("is_self_message", False)

message_id = payload.get("messageId", "")

# The listener capped a long body (PlatformMessage.truncated):
# mark the cut where both the agent and the chat details see it.
truncated = bool(payload.get("truncated")) and bool(message_body)
if truncated:
message_body = f"{message_body}…"

# Normalized attachments (PlatformMessage.attachments) become
# descriptor lines with retrieval hints — appended to the body,
# or standing in for it on media-only messages so they are no
# longer dropped (docs/plans/attachment-reception-plan.md).
# longer dropped.
from app.integrations import format_attachment_descriptors

att_lines = format_attachment_descriptors(
Expand Down Expand Up @@ -2518,11 +2526,28 @@ async def _handle_external_event(self, payload: Dict) -> None:
location_parts.append(f"channel {channel_id}")
location_str = f" in {' / '.join(location_parts)}" if location_parts else ""

# Tell the agent the body above is partial and how to get the
# rest; whether the rest is needed stays the agent's call.
truncation_note = ""
if truncated:
fetch_hint = (
f" If the rest matters, read the full message with the "
f"{integration_type} action that gets a message by ID "
f"(message ID: {message_id})."
if message_id
else ""
)
truncation_note = (
f"[This message is truncated: only the beginning is shown "
f"above.{fetch_hint}]\n"
)

if is_self_message:
# Self-message = user is directly talking to the agent via their own platform.
event_content = (
f"[USER SELF-MESSAGE via {source}]\n"
f"{message_body}\n\n"
f"{message_body}\n"
f"{truncation_note}\n"
f"INSTRUCTIONS: Reply to the message to the user on {source}"
f"{account_note}"
)
Expand All @@ -2538,7 +2563,8 @@ async def _handle_external_event(self, payload: Dict) -> None:
f"From: {contact_name} ({contact_id}){location_str}\n"
f"Platform: {source}\n"
f"{received_on}"
f'Message: "{message_body}"\n\n'
f'Message: "{message_body}"\n'
f"{truncation_note}\n"
f"INSTRUCTIONS: Notify the user about this message on their "
f"preferred platform (check USER.md 'Preferred Messaging "
f"Platform'). If USER.md does not name one, notify via "
Expand Down
11 changes: 10 additions & 1 deletion craftos_integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,18 @@ has `listen` enabled, and reconciles whenever accounts change.
"messageId": "<platform message id>",
"is_self_message": False,
"raw": {...}, # full original platform event
"attachments": [...], # normalized non-text payloads ({kind, id, name, mime, size, url, extra})
"truncated": False, # True when the listener capped messageBody
}
```

`messageBody` is plain, decoded text built from the real message body —
listeners unescape HTML entities and render platform markup before
emitting, and never forward an API preview field (Gmail `snippet`,
Outlook `bodyPreview`) as the body. A listener that caps the length uses
`helpers.clip` and sets `truncated`; the host then marks the cut and tells
the agent the message is truncated and how to read the rest by message id.

---

## Configuration: OAuth env vars
Expand Down Expand Up @@ -399,7 +408,7 @@ For a production-level integration, produce in this order:
| 2 | Implement `verify_token` (token auth) or `oauth_spec` (OAuth), plus `identity_of` | `provider.py` |
| 3 | Optional: `config_class` + `config_fields` for post-connect knobs | `provider.py` |
| 4 | Build the client — one method per endpoint, using `helpers.arequest`, returning `Result` | client in `__init__.py` |
| 5 | Optional: `start_listening` / `stop_listening` (webhook / polling / WebSocket) | client |
| 5 | Optional: `start_listening` / `stop_listening` (webhook / polling / WebSocket). Emit **plain, decoded** text from the real body (unescape entities, render platform markup like `<@U…>`; no API preview fields); cap long bodies with `helpers.clip` and pass its flag as `PlatformMessage.truncated` | client |
| 6 | Write `INTEGRATION.md` — identifier shape, silent-drop config flags, auth gotchas | integration root |
| 7 | Mirror each client method as a `client_op` with sub-set + umbrella tags | `operations.py` |
| 8 | Verify — `scripts/verify_integration.py <name>`, then a live smoke test | see "Verification" |
Expand Down
4 changes: 4 additions & 0 deletions craftos_integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ class PlatformMessage:
# The HOST formats these into descriptor text + retrieval hints —
# listeners only normalize (docs/plans/attachment-reception-plan.md).
attachments: List[Dict[str, Any]] = field(default_factory=list)
# True when the listener capped ``text`` (``helpers.clip``). The HOST
# marks the cut and gives the agent the message id to fetch the rest;
# listeners only declare the fact.
truncated: bool = False


MessageCallback = Callable[[PlatformMessage], Awaitable[None]]
Expand Down
4 changes: 4 additions & 0 deletions craftos_integrations/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@
``status_code → {ok, result} | {error, details}`` envelope shape.
result: ``Result`` / ``Ok`` / ``Err`` TypedDict aliases for the envelope —
use as return annotations for static type-checking benefits.
text: ``clip`` — word-boundary length cap for listener message bodies
that reports whether it cut (→ ``PlatformMessage.truncated``).
"""

from .http import arequest, request
from .result import Err, Ok, Result
from .text import clip

__all__ = [
"Err",
"Ok",
"Result",
"arequest",
"clip",
"request",
]
29 changes: 29 additions & 0 deletions craftos_integrations/helpers/text.py
Comment thread
ahmad-ajmal marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Text shaping for listener message bodies.

Listeners that cap a body's length (to keep inbound token cost bounded)
use ``clip`` so every cap cuts the same way and reports that it cut —
the flag goes to ``PlatformMessage.truncated`` and the host marks the cut.
"""

from __future__ import annotations

from typing import Tuple

# How far back from the limit a word boundary may be before we give up
# and hard-cut (fraction of the limit).
_BOUNDARY_WINDOW = 0.2


def clip(text: str, limit: int) -> Tuple[str, bool]:
"""Cut ``text`` to at most ``limit`` chars, preferring a word boundary.

Returns ``(clipped, was_clipped)``. No ellipsis is added — the host
owns presentation of the cut.
"""
if len(text) <= limit:
return text, False
head = text[:limit]
cut = max(head.rfind(" "), head.rfind("\n"))
if cut >= limit * (1 - _BOUNDARY_WINDOW):
head = head[:cut]
return head.rstrip(), True
1 change: 1 addition & 0 deletions craftos_integrations/providers/_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def platform_message_payload(msg: Any) -> Dict[str, Any]:
"is_self_message": raw.get("is_self_message", False),
"raw": raw,
"attachments": list(getattr(msg, "attachments", None) or []),
"truncated": bool(getattr(msg, "truncated", False)),
}


Expand Down
11 changes: 9 additions & 2 deletions craftos_integrations/providers/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
register_client,
save_credential,
)
from ...helpers import Result, arequest
from ...helpers import Result, arequest, clip
from ...logger import get_logger

logger = get_logger(__name__)
Expand All @@ -40,6 +40,10 @@
POLL_INTERVAL = 15
RETRY_DELAY = 30

# Inbound notification comments are previewed at this length (token cost);
# a longer comment is flagged as PlatformMessage.truncated.
_COMMENT_PREVIEW_CHARS = 300


@dataclass
class GitHubCredential:
Expand Down Expand Up @@ -336,8 +340,10 @@ async def _dispatch_notification(self, notif: Dict[str, Any]) -> None:
f"[{repo_full}] {subject_type}: {subject_title}",
f"Reason: {reason}",
]
truncated = False
if comment_body:
text_parts.append(f"Comment by @{comment_author}: {comment_body[:300]}")
preview, truncated = clip(comment_body, _COMMENT_PREVIEW_CHARS)
text_parts.append(f"Comment by @{comment_author}: {preview}")

await self._message_callback(
PlatformMessage(
Expand All @@ -350,6 +356,7 @@ async def _dispatch_notification(self, notif: Dict[str, Any]) -> None:
message_id=notif.get("id", ""),
timestamp=datetime.now(timezone.utc),
raw=notif,
truncated=truncated,
)
)

Expand Down
Loading