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.
Files changed (55) hide show
  1. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/PKG-INFO +1 -1
  2. evalforge_runtime-0.2.2/docs/decisions/001-file-forwarding-in-process-chains.md +34 -0
  3. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/pyproject.toml +1 -1
  4. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/process_call.py +8 -0
  5. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/gmail.py +100 -0
  6. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/files.py +3 -0
  7. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/.github/workflows/workflow.yml +0 -0
  8. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/.gitignore +0 -0
  9. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/LICENSE +0 -0
  10. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/README.md +0 -0
  11. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/fixtures/evalforge.config.yaml +0 -0
  12. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/__init__.py +0 -0
  13. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/__main__.py +0 -0
  14. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/__init__.py +0 -0
  15. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/base.py +0 -0
  16. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/__init__.py +0 -0
  17. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_forward.py +0 -0
  18. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_mark_read.py +0 -0
  19. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_move.py +0 -0
  20. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_reply.py +0 -0
  21. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/email_send.py +0 -0
  22. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/file_save_output.py +0 -0
  23. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/gdrive_upload.py +0 -0
  24. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/sharepoint_upload.py +0 -0
  25. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/builtins/webhook_post.py +0 -0
  26. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/actions/runner.py +0 -0
  27. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/auth.py +0 -0
  28. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/condition.py +0 -0
  29. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/config.py +0 -0
  30. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/__init__.py +0 -0
  31. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/base.py +0 -0
  32. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/exchange.py +0 -0
  33. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/slack.py +0 -0
  34. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/connectors/webhook.py +0 -0
  35. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/db.py +0 -0
  36. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/executor.py +0 -0
  37. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/observability.py +0 -0
  38. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/pipeline.py +0 -0
  39. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/scheduler.py +0 -0
  40. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/server.py +0 -0
  41. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/storage.py +0 -0
  42. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/types.py +0 -0
  43. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/src/evalforge_runtime/ui.py +0 -0
  44. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/__init__.py +0 -0
  45. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/conftest.py +0 -0
  46. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_config.py +0 -0
  47. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_connectors.py +0 -0
  48. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_db.py +0 -0
  49. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_executor.py +0 -0
  50. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_langfuse.py +0 -0
  51. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_pipeline.py +0 -0
  52. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_reviews.py +0 -0
  53. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_server.py +0 -0
  54. {evalforge_runtime-0.2.0 → evalforge_runtime-0.2.2}/tests/test_ui.py +0 -0
  55. {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.0
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "evalforge-runtime"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Runtime engine for EvalForge generated applications"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
@@ -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,