evalforge-runtime 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/PKG-INFO +1 -1
- evalforge_runtime-0.2.2/docs/decisions/001-file-forwarding-in-process-chains.md +34 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/pyproject.toml +1 -1
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/process_call.py +8 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/gmail.py +100 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/files.py +3 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/.github/workflows/workflow.yml +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/.gitignore +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/LICENSE +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/README.md +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/fixtures/evalforge.config.yaml +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/__init__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/__main__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/__init__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/base.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/__init__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_forward.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_mark_read.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_move.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_reply.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_send.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/file_save_output.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/gdrive_upload.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/sharepoint_upload.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/webhook_post.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/runner.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/auth.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/condition.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/config.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/__init__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/base.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/exchange.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/slack.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/webhook.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/db.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/executor.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/observability.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/pipeline.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/scheduler.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/server.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/storage.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/types.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/ui.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/__init__.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/conftest.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_config.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_connectors.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_db.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_executor.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_langfuse.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_pipeline.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_reviews.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_server.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_ui.py +0 -0
- {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evalforge-runtime
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Runtime engine for EvalForge generated applications
|
|
5
5
|
Project-URL: Homepage, https://github.com/JannisConen/evalforge-runtime
|
|
6
6
|
Project-URL: Repository, https://github.com/JannisConen/evalforge-runtime
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# ADR 001: File Forwarding in Process Chains
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
When one process calls another via `process.call`, the field mappings can reference files from the original trigger input (e.g. `input.email`). By the time the after step runs, `context["input"]` contains the already-resolved file dict — i.e. what came out of `resolve_file_refs()`. This dict has `content` (decoded text) and `path` (local filesystem path) added to it.
|
|
6
|
+
|
|
7
|
+
Naively forwarding this resolved dict over HTTP to the downstream process causes two problems:
|
|
8
|
+
|
|
9
|
+
1. **Encoding errors on servers with ASCII locale** (`LANG=C`, common in Docker). The decoded content may contain non-ASCII characters (e.g. German `ö`, `ü`). Even though httpx uses `ensure_ascii=False`, the Python process itself or logging handlers may have an ASCII default encoding, causing `UnicodeEncodeError`.
|
|
10
|
+
|
|
11
|
+
2. **Inflated payload size.** The decoded email text is duplicated in the HTTP body, even though the downstream process would re-read it from storage anyway.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
**Send clean FileRefs across process boundaries, never resolved content.**
|
|
16
|
+
|
|
17
|
+
Two changes enforce this:
|
|
18
|
+
|
|
19
|
+
1. **`files.py` — preserve `type` and `key` on resolved dicts.** After resolving a FileRef, the result dict retains `type: "local"` and `key: <storage key>`. This makes the resolved dict a valid FileRef so it can be re-resolved by a downstream process.
|
|
20
|
+
|
|
21
|
+
2. **`process_call.py` — strip `content` and `path` before forwarding.** In `_build_input()`, any value that looks like a local/s3/url FileRef (has `type` and `key`) has its `content` and `path` fields removed before being sent. The downstream process receives only the FileRef metadata and re-resolves the file from shared storage.
|
|
22
|
+
|
|
23
|
+
## Consequences
|
|
24
|
+
|
|
25
|
+
- Files must be in shared storage accessible to all processes on the same runtime. This is the case by default (single runtime, `LocalStorage`). For multi-host deployments, an S3 or shared filesystem storage backend is required anyway.
|
|
26
|
+
- Downstream processes always get fresh file resolution (re-read from storage, re-decoded), which is correct.
|
|
27
|
+
- The `content` field is never available in `context["input"]` inside after.py — only the raw file dict with FileRef fields. This is intentional; only execution.py needs `content`.
|
|
28
|
+
|
|
29
|
+
## Open Questions / TODOs
|
|
30
|
+
|
|
31
|
+
- The connector `file` → InputSchema field name aliasing (`_apply_connector_file_aliases` in `pipeline.py`) is a runtime workaround for a mismatch between connector output keys and generated InputSchema field names. A cleaner long-term solution would be to either:
|
|
32
|
+
- Teach the agent (via `trigger-action-registry.md`) that email triggers always expose the `.eml` file at `input.file`, and have the agent generate field mappings with `input.file` as source.
|
|
33
|
+
- Standardize connectors to always use the same key (e.g. always `file`), and keep generated InputSchemas consistent.
|
|
34
|
+
- Current approach: runtime inspects the InputSchema at pipeline entry and adds an alias if a FileInput field with a different name is found and `file` is present in the input dict.
|
|
@@ -10,6 +10,8 @@ import importlib
|
|
|
10
10
|
import logging
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
+
import json as _json
|
|
14
|
+
|
|
13
15
|
import httpx
|
|
14
16
|
|
|
15
17
|
from evalforge_runtime.actions.base import BaseAction
|
|
@@ -137,6 +139,12 @@ class ProcessCallAction(BaseAction):
|
|
|
137
139
|
if transform_code:
|
|
138
140
|
value = self._apply_transform(transform_code, value, output)
|
|
139
141
|
|
|
142
|
+
# Strip resolved-only fields before forwarding — downstream re-resolves from storage.
|
|
143
|
+
# Passing large decoded content (e.g. full email text) over HTTP can cause
|
|
144
|
+
# encoding errors on servers with ASCII locale (LANG=C) and inflates payload size.
|
|
145
|
+
if isinstance(value, dict) and value.get("type") in ("local", "s3", "url") and value.get("key"):
|
|
146
|
+
value = {k: v for k, v in value.items() if k not in ("content", "path")}
|
|
147
|
+
|
|
140
148
|
self._set_path(result, target_path, value)
|
|
141
149
|
|
|
142
150
|
return result
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/gmail.py
RENAMED
|
@@ -447,6 +447,106 @@ class GmailConnector(Connector):
|
|
|
447
447
|
resp.raise_for_status()
|
|
448
448
|
logger.info("Reply sent via Gmail API (thread=%s)", thread_id)
|
|
449
449
|
|
|
450
|
+
# -----------------------------------------------------------------------
|
|
451
|
+
# Forward
|
|
452
|
+
# -----------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
async def forward(self, message_id: str, to: str, body: str = "") -> None:
|
|
455
|
+
"""Forward a message to another recipient."""
|
|
456
|
+
logger.info("Forwarding message %s to %s", message_id, to)
|
|
457
|
+
if self._auth_method != "service_account":
|
|
458
|
+
await self._forward_smtp(message_id, to, body)
|
|
459
|
+
else:
|
|
460
|
+
await self._forward_api(message_id, to, body)
|
|
461
|
+
|
|
462
|
+
async def _forward_smtp(self, message_id: str, to: str, body: str) -> None:
|
|
463
|
+
"""Forward via SMTP — fetches original via IMAP and re-sends."""
|
|
464
|
+
def _do_forward() -> None:
|
|
465
|
+
mail = imaplib.IMAP4_SSL("imap.gmail.com")
|
|
466
|
+
mail.login(self._mailbox, self.secrets["GMAIL_APP_PASSWORD"])
|
|
467
|
+
mail.select("INBOX")
|
|
468
|
+
|
|
469
|
+
_, msg_data = mail.fetch(message_id.encode(), "(RFC822)")
|
|
470
|
+
if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple):
|
|
471
|
+
raise ValueError(f"Could not fetch original message {message_id}")
|
|
472
|
+
|
|
473
|
+
original = email_lib.message_from_bytes(msg_data[0][1])
|
|
474
|
+
subject = str(original.get("Subject", ""))
|
|
475
|
+
fwd_subject = subject if subject.lower().startswith("fwd:") else f"Fwd: {subject}"
|
|
476
|
+
|
|
477
|
+
import email.mime.multipart
|
|
478
|
+
fwd = email.mime.multipart.MIMEMultipart()
|
|
479
|
+
fwd["From"] = self._mailbox
|
|
480
|
+
fwd["To"] = to
|
|
481
|
+
fwd["Subject"] = fwd_subject
|
|
482
|
+
|
|
483
|
+
if body:
|
|
484
|
+
fwd.attach(email.mime.text.MIMEText(body, "html"))
|
|
485
|
+
|
|
486
|
+
# Attach original as message/rfc822
|
|
487
|
+
import email.mime.message
|
|
488
|
+
attached = email.mime.message.MIMEMessage(original)
|
|
489
|
+
fwd.attach(attached)
|
|
490
|
+
|
|
491
|
+
with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
|
|
492
|
+
server.login(self._mailbox, self.secrets["GMAIL_APP_PASSWORD"])
|
|
493
|
+
server.send_message(fwd)
|
|
494
|
+
logger.info("Message %s forwarded via SMTP to %s", message_id, to)
|
|
495
|
+
mail.logout()
|
|
496
|
+
|
|
497
|
+
await asyncio.to_thread(_do_forward)
|
|
498
|
+
|
|
499
|
+
async def _forward_api(self, message_id: str, to: str, body: str) -> None:
|
|
500
|
+
"""Forward via Gmail API — fetches raw message and re-sends."""
|
|
501
|
+
token = await self._acquire_token()
|
|
502
|
+
|
|
503
|
+
async with httpx.AsyncClient() as client:
|
|
504
|
+
# Fetch original metadata for subject
|
|
505
|
+
resp = await client.get(
|
|
506
|
+
f"https://gmail.googleapis.com/gmail/v1/users/{self._mailbox}"
|
|
507
|
+
f"/messages/{message_id}?format=metadata",
|
|
508
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
509
|
+
)
|
|
510
|
+
resp.raise_for_status()
|
|
511
|
+
msg = resp.json()
|
|
512
|
+
headers_map = {
|
|
513
|
+
h["name"].lower(): h["value"]
|
|
514
|
+
for h in msg.get("payload", {}).get("headers", [])
|
|
515
|
+
}
|
|
516
|
+
subject = headers_map.get("subject", "")
|
|
517
|
+
fwd_subject = subject if subject.lower().startswith("fwd:") else f"Fwd: {subject}"
|
|
518
|
+
|
|
519
|
+
# Fetch raw RFC822
|
|
520
|
+
raw_resp = await client.get(
|
|
521
|
+
f"https://gmail.googleapis.com/gmail/v1/users/{self._mailbox}"
|
|
522
|
+
f"/messages/{message_id}?format=raw",
|
|
523
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
524
|
+
)
|
|
525
|
+
raw_resp.raise_for_status()
|
|
526
|
+
raw_bytes = base64.urlsafe_b64decode(raw_resp.json().get("raw", ""))
|
|
527
|
+
|
|
528
|
+
original = email_lib.message_from_bytes(raw_bytes)
|
|
529
|
+
|
|
530
|
+
import email.mime.multipart
|
|
531
|
+
import email.mime.message
|
|
532
|
+
fwd = email.mime.multipart.MIMEMultipart()
|
|
533
|
+
fwd["to"] = to
|
|
534
|
+
fwd["subject"] = fwd_subject
|
|
535
|
+
if body:
|
|
536
|
+
fwd.attach(email.mime.text.MIMEText(body, "html"))
|
|
537
|
+
fwd.attach(email.mime.message.MIMEMessage(original))
|
|
538
|
+
|
|
539
|
+
raw_fwd = base64.urlsafe_b64encode(fwd.as_bytes()).decode("utf-8")
|
|
540
|
+
|
|
541
|
+
async with httpx.AsyncClient() as client:
|
|
542
|
+
resp = await client.post(
|
|
543
|
+
f"https://gmail.googleapis.com/gmail/v1/users/{self._mailbox}/messages/send",
|
|
544
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
545
|
+
json={"raw": raw_fwd},
|
|
546
|
+
)
|
|
547
|
+
resp.raise_for_status()
|
|
548
|
+
logger.info("Message %s forwarded via Gmail API to %s", message_id, to)
|
|
549
|
+
|
|
450
550
|
# -----------------------------------------------------------------------
|
|
451
551
|
# Move / Mark read
|
|
452
552
|
# -----------------------------------------------------------------------
|
|
@@ -206,6 +206,9 @@ async def _resolve_single(
|
|
|
206
206
|
local_path = str(storage.base_path / local_key)
|
|
207
207
|
|
|
208
208
|
result: dict[str, Any] = {
|
|
209
|
+
# Keep type/key so downstream processes can re-resolve this as a FileRef
|
|
210
|
+
"type": "local",
|
|
211
|
+
"key": local_key,
|
|
209
212
|
"filename": filename,
|
|
210
213
|
"path": local_path,
|
|
211
214
|
"mimeType": mime_type,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/__init__.py
RENAMED
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/base.py
RENAMED
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/exchange.py
RENAMED
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/slack.py
RENAMED
|
File without changes
|
{evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/webhook.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|