Skip to content
Open
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
55 changes: 55 additions & 0 deletions .github/workflows/build-cr-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Build & Push CR Custom Image

on:
push:
branches:
- feature/mattermost-ambient-session-ingestion

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this branch-specific publishing workflow from the feature PR. It is unrelated to Mattermost ingestion and grants package-publishing capability for an external Coding-Reality image.

paths:
- '**/*.py'
- 'pyproject.toml'
- 'uv.lock'
- 'Dockerfile'
- 'docker/**'
- '.github/workflows/build-cr-image.yml'

permissions:
contents: read
packages: write

concurrency:
group: cr-docker-${{ github.ref }}
cancel-in-progress: true

env:
IMAGE: ghcr.io/coding-reality/hermes-agent

jobs:
build-push:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Log in to GHCR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=gha,scope=cr-hermes-amd64
cache-to: type=gha,mode=max,scope=cr-hermes-amd64
75 changes: 60 additions & 15 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,22 +780,67 @@ def _check_cancelled() -> None:
timeout_timer.daemon = True
timeout_timer.start()
_check_cancelled()
with self._client.responses.stream(**resp_kwargs) as stream:
for _event in stream:
try:
with self._client.responses.stream(**resp_kwargs) as stream:
for _event in stream:
_check_cancelled()
_etype = getattr(_event, "type", "")
if _etype == "response.output_item.done":
_done = getattr(_event, "item", None)
if _done is not None:
collected_output_items.append(_done)
elif "output_text.delta" in _etype:
_delta = getattr(_event, "delta", "")
if _delta:
collected_text_deltas.append(_delta)
elif "function_call" in _etype:
has_function_calls = True
_check_cancelled()
_etype = getattr(_event, "type", "")
if _etype == "response.output_item.done":
_done = getattr(_event, "item", None)
if _done is not None:
collected_output_items.append(_done)
elif "output_text.delta" in _etype:
_delta = getattr(_event, "delta", "")
if _delta:
collected_text_deltas.append(_delta)
elif "function_call" in _etype:
has_function_calls = True
_check_cancelled()
final = stream.get_final_response()
final = stream.get_final_response()
except TypeError as exc:
err_text = str(exc)
output_none_parse_error = (
"NoneType" in err_text
and "not iterable" in err_text
and (collected_output_items or collected_text_deltas)
)
if not output_none_parse_error:
raise
if collected_output_items:
final = SimpleNamespace(
output=list(collected_output_items),
usage=None,
status="completed",
)
logger.debug(
"Codex auxiliary: recovered %d output items after SDK "
"terminal response output=None parse error",
len(collected_output_items),
)
elif collected_text_deltas and not has_function_calls:
assembled = "".join(collected_text_deltas)
final = SimpleNamespace(
output=[
SimpleNamespace(
type="message",
role="assistant",
status="completed",
content=[
SimpleNamespace(type="output_text", text=assembled)
],
)
],
usage=None,
status="completed",
)
logger.debug(
"Codex auxiliary: synthesized from %d deltas after SDK "
"terminal response output=None parse error (%d chars)",
len(collected_text_deltas),
len(assembled),
)
else:
raise

# Backfill empty output from collected stream events
_output = getattr(final, "output", None)
Expand Down
44 changes: 44 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,12 @@ class MessageEvent:
# completion notifications) that must bypass user authorization checks.
internal: bool = False

# When False, the message is ingested into session history but does NOT
# trigger an LLM response. Used by ambient/silent-ingestion channels
# (e.g. MATTERMOST_AMBIENT_CHANNELS) where Hermes should accumulate
# context passively and only respond when explicitly triggered.
trigger_llm: bool = True

# Timestamps
timestamp: datetime = field(default_factory=datetime.now)

Expand Down Expand Up @@ -2816,10 +2822,48 @@ async def handle_message(self, event: MessageEvent) -> None:
This method returns quickly by spawning background tasks.
This allows new messages to be processed even while an agent is running,
enabling interruption support.

When ``event.trigger_llm`` is False (ambient/silent-ingestion mode) the
message is appended to the session transcript so it becomes part of the
conversational context, but no LLM response is generated.
"""
if not self._message_handler:
return

# Ambient ingestion: store to session history without invoking the LLM.
if not event.trigger_llm:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns before the GatewayRunner handler, which performs the normal authorization/pairing gate. An unapproved participant can therefore write [sender]: text into a session that an authorized user later loads. Preserve authorization before any ambient transcript write.

if self._session_store is not None:
try:
session_entry = self._session_store.get_or_create_session(event.source)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This derives the transcript from the individual sender's event.source. With the default per-user group-session isolation, ambient posts from different channel members land in different sessions, so the later mention cannot receive the claimed channel-wide accumulated context.

# Prefix the message with sender attribution so the agent
# has full context when it is eventually triggered.
sender = (
event.source.user_name
or event.source.user_id
or "unknown"
) if event.source else "unknown"
attributed_content = f"[{sender}]: {event.text}"
self._session_store.append_to_transcript(
session_entry.session_id,
{
"role": "user",
"content": attributed_content,
"ambient": True,
"sender_id": event.source.user_id if event.source else None,
"sender_name": event.source.user_name if event.source else None,
},
)
logger.debug(
"Ambient ingestion: stored message from %s to session %s (no LLM invocation)",
sender,
session_entry.session_id,
)
except Exception as exc:
logger.warning("Ambient ingestion: failed to store message: %s", exc)
else:
logger.debug("Ambient ingestion: session_store not available, message discarded")
return

coerce_plaintext_gateway_command(event)

session_key = build_session_key(
Expand Down
Loading