dispatch_agents 0.12.2__tar.gz → 0.13.3__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 (149) hide show
  1. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/PKG-INFO +1 -1
  2. dispatch_agents-0.13.3/RELEASE_NOTES.md +2 -0
  3. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/__init__.py +3 -0
  4. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/config.py +165 -2
  5. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/integrations/github/__init__.py +31 -13
  6. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/models.py +11 -4
  7. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/dispatch.yaml +0 -1
  8. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/conversational-agent/dispatch.yaml +0 -1
  9. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/daily-digest/dispatch.yaml +0 -1
  10. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/dispatch.yaml +0 -1
  11. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/agent.py +105 -12
  12. dispatch_agents-0.13.3/examples/hello_world/dispatch.yaml +16 -0
  13. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/uv.lock +50 -49
  14. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/dispatch.yaml +0 -1
  15. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/multi-framework/dispatch.yaml +0 -1
  16. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/dispatch.yaml +0 -1
  17. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/dispatch.yaml +0 -1
  18. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/pyproject.toml +2 -1
  19. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_config.py +240 -0
  20. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_github_integration.py +384 -429
  21. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_github_schema_compliance.py +93 -0
  22. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/uv.lock +367 -328
  23. dispatch_agents-0.12.2/RELEASE_NOTES.md +0 -1
  24. dispatch_agents-0.12.2/examples/hello_world/dispatch.yaml +0 -9
  25. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.claude-plugin/marketplace.json +0 -0
  26. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/scripts/change_scope.py +0 -0
  27. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/scripts/ci_git.py +0 -0
  28. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/scripts/version_policy.py +0 -0
  29. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/tests/test_change_scope.py +0 -0
  30. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/tests/test_ci_git.py +0 -0
  31. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/tests/test_version_policy.py +0 -0
  32. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/workflows/ci-reusable.yml +0 -0
  33. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/workflows/feature-branch.yml +0 -0
  34. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/workflows/release.yml +0 -0
  35. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.github/workflows/version-policy-reusable.yml +0 -0
  36. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/.gitignore +0 -0
  37. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/CONTRIBUTING.md +0 -0
  38. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/LICENSE +0 -0
  39. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/LICENSE-3rdparty.csv +0 -0
  40. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/NOTICE +0 -0
  41. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/README.md +0 -0
  42. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/__init__.py +0 -0
  43. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/py.typed +0 -0
  44. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/__init__.py +0 -0
  45. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/message_pb2.py +0 -0
  46. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/message_pb2.pyi +0 -0
  47. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/message_pb2_grpc.py +0 -0
  48. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/request_response_pb2.py +0 -0
  49. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/request_response_pb2.pyi +0 -0
  50. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/request_response_pb2_grpc.py +0 -0
  51. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/service_pb2.py +0 -0
  52. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/service_pb2.pyi +0 -0
  53. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/agentservice/v1/service_pb2_grpc.py +0 -0
  54. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/agent_service.py +0 -0
  55. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/contrib/__init__.py +0 -0
  56. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/contrib/claude/__init__.py +0 -0
  57. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/contrib/openai/__init__.py +0 -0
  58. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/events.py +0 -0
  59. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/grpc_server.py +0 -0
  60. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/instrument.py +0 -0
  61. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/integrations/__init__.py +0 -0
  62. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/integrations/github/README.md +0 -0
  63. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/integrations/github/client.py +0 -0
  64. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/invocation.py +0 -0
  65. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/llm.py +0 -0
  66. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/llm_langchain.py +0 -0
  67. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/logging_config.py +0 -0
  68. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/mcp.py +0 -0
  69. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/memory.py +0 -0
  70. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/proxy/__init__.py +0 -0
  71. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/proxy/server.py +0 -0
  72. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/proxy/sse_utils.py +0 -0
  73. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/py.typed +0 -0
  74. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/resources.py +0 -0
  75. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/dispatch_agents/version.py +0 -0
  76. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/README.md +0 -0
  77. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/.gitignore +0 -0
  78. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/AGENTS.md +0 -0
  79. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/README.md +0 -0
  80. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/agent.py +0 -0
  81. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/pyproject.toml +0 -0
  82. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/company-researcher/uv.lock +0 -0
  83. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/conversational-agent/README.md +0 -0
  84. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/conversational-agent/agent.py +0 -0
  85. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/conversational-agent/pyproject.toml +0 -0
  86. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/daily-digest/README.md +0 -0
  87. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/daily-digest/agent.py +0 -0
  88. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/daily-digest/pyproject.toml +0 -0
  89. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/README.md +0 -0
  90. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/agent.py +0 -0
  91. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/configuration.py +0 -0
  92. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/deep_researcher.py +0 -0
  93. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/prompts.py +0 -0
  94. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/pyproject.toml +0 -0
  95. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/state.py +0 -0
  96. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/deep-research/tools.py +0 -0
  97. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/.gitignore +0 -0
  98. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/AGENTS.md +0 -0
  99. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/pyproject.toml +0 -0
  100. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/hello_world/test_agent.py +0 -0
  101. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/.env.example +0 -0
  102. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/.gitignore +0 -0
  103. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/AGENTS.md +0 -0
  104. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/agent.py +0 -0
  105. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/knowledge-base-query/pyproject.toml +0 -0
  106. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/multi-framework/README.md +0 -0
  107. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/multi-framework/agent.py +0 -0
  108. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/multi-framework/pyproject.toml +0 -0
  109. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/pyproject.toml +0 -0
  110. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/uv.lock +0 -0
  111. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/.gitignore +0 -0
  112. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/AGENTS.md +0 -0
  113. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/agent.py +0 -0
  114. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/pyproject.toml +0 -0
  115. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-assistant/uv.lock +0 -0
  116. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/.gitignore +0 -0
  117. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/AGENTS.md +0 -0
  118. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/agent.py +0 -0
  119. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/pyproject.toml +0 -0
  120. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/examples/weather-service/uv.lock +0 -0
  121. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/internal/py.typed +0 -0
  122. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/plugins/README.md +0 -0
  123. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/__init__.py +0 -0
  124. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/e2e_claude_mcp_proxy.py +0 -0
  125. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/schemas/README.md +0 -0
  126. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/schemas/octokit-webhooks.json +0 -0
  127. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test.py +0 -0
  128. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_agent_service.py +0 -0
  129. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_agent_uid.py +0 -0
  130. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_contrib_claude.py +0 -0
  131. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_contrib_openai.py +0 -0
  132. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_dev_mode_isolation.py +0 -0
  133. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_extra_headers.py +0 -0
  134. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_fn_decorator.py +0 -0
  135. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_github_client.py +0 -0
  136. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_grpc_server.py +0 -0
  137. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_init.py +0 -0
  138. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_instrument.py +0 -0
  139. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_llm_langchain.py +0 -0
  140. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_llm_logging.py +0 -0
  141. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_logging_config.py +0 -0
  142. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_mcp.py +0 -0
  143. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_memory.py +0 -0
  144. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_proxy_e2e.py +0 -0
  145. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_proxy_server.py +0 -0
  146. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_resources.py +0 -0
  147. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_sse_utils.py +0 -0
  148. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_trace_context.py +0 -0
  149. {dispatch_agents-0.12.2 → dispatch_agents-0.13.3}/tests/test_typed_events.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dispatch_agents
3
- Version: 0.12.2
3
+ Version: 0.13.3
4
4
  Summary: Dispatch Agents SDK
5
5
  License-File: LICENSE
6
6
  License-File: LICENSE-3rdparty.csv
@@ -0,0 +1,2 @@
1
+ ## Bug Fixes
2
+ - Custom `base_image` configurations are now explicitly rejected with an error rather than silently ignored, providing clearer feedback when unsupported options are supplied.
@@ -162,6 +162,7 @@ def _dev_mode_audit_hook(event: str, args: tuple) -> None:
162
162
 
163
163
 
164
164
  from .agent_service import AgentServiceClient
165
+ from .config import _runtime as config
165
166
  from .events import (
166
167
  HANDLER_METADATA,
167
168
  REGISTERED_HANDLERS,
@@ -208,6 +209,8 @@ from .models import (
208
209
  )
209
210
 
210
211
  __all__ = [
212
+ # runtime config
213
+ "config",
211
214
  # storage and dev mode isolation
212
215
  "get_data_dir",
213
216
  "DisallowedWriteError",
@@ -433,7 +433,6 @@ class DispatchConfig(BaseModel):
433
433
  namespace: skunkworks
434
434
  agent_name: my-agent
435
435
  entrypoint: agent.py
436
- base_image: python:3.13-slim
437
436
  env:
438
437
  LOG_LEVEL: debug
439
438
  MY_APP_MODE: production
@@ -464,7 +463,10 @@ class DispatchConfig(BaseModel):
464
463
  )
465
464
  base_image: str | None = Field(
466
465
  default=None,
467
- description="Base Docker image for the agent container",
466
+ description=(
467
+ "Currently only python:3.13-slim is supported; defaults to it "
468
+ "when omitted. Other values are rejected at deploy time."
469
+ ),
468
470
  )
469
471
  system_packages: list[str] | None = Field(
470
472
  default=None,
@@ -500,6 +502,49 @@ class DispatchConfig(BaseModel):
500
502
  raise ValueError(f"All env values must be strings. {examples}")
501
503
  return v
502
504
 
505
+ vars: dict[str, Any] | None = Field(
506
+ default=None,
507
+ description="Configuration variables accessible at runtime via dispatch_agents.config.vars. "
508
+ "Unlike env, these are NOT injected as environment variables. "
509
+ "Supports any YAML-serializable type. Use {value: <any>, description: <str>} "
510
+ "to attach descriptions for the UI.",
511
+ )
512
+
513
+ @field_validator("vars", mode="before")
514
+ @classmethod
515
+ def _validate_vars(cls, v: dict | None) -> dict[str, Any] | None:
516
+ """Validate vars format.
517
+
518
+ Non-dict values (str, int, float, bool, list, None) are allowed as-is.
519
+ Dict values must use the described-var shape:
520
+ ``{value: <any>, description?: <str>}``. Other dicts are rejected
521
+ because ``value`` and ``description`` are reserved keywords — silently
522
+ treating arbitrary dicts as plain values would let a caller
523
+ accidentally collide with the described-var schema.
524
+ """
525
+ if not v or not isinstance(v, dict):
526
+ return v
527
+ _DESCRIBED_KEYS = {"value", "description"}
528
+ for key, val in v.items():
529
+ if not isinstance(val, dict):
530
+ continue
531
+ if "value" not in val:
532
+ raise ValueError(
533
+ f"Var '{key}' is a dict without a 'value' key. Dict values "
534
+ "must use the described-var shape "
535
+ "{value: <any>, description?: <str>}. To store structured "
536
+ "data, nest it under 'value'."
537
+ )
538
+ extra = set(val.keys()) - _DESCRIBED_KEYS
539
+ if extra:
540
+ raise ValueError(
541
+ f"Var '{key}' has unexpected keys {sorted(extra)}. Dict "
542
+ "values may only use the described-var shape "
543
+ "{value: <any>, description?: <str>}. To store structured "
544
+ "data, nest it under 'value'."
545
+ )
546
+ return v
547
+
503
548
  secrets: list[SecretConfig] | None = Field(
504
549
  default=None,
505
550
  description="Secrets to inject as environment variables",
@@ -573,10 +618,14 @@ class DispatchConfig(BaseModel):
573
618
  result["local_dependencies"] = self.local_dependencies
574
619
  if self.env:
575
620
  result["env"] = dict(self.env)
621
+ if self.vars:
622
+ result["vars"] = dict(self.vars)
576
623
  if self.secrets:
577
624
  result["secrets"] = [
578
625
  {"name": s.name, "secret_id": s.secret_id} for s in self.secrets
579
626
  ]
627
+ if self.mcp_servers:
628
+ result["mcp_servers"] = [{"server": m.server} for m in self.mcp_servers]
580
629
  if self.volumes:
581
630
  result["volumes"] = [
582
631
  {"name": v.name, "mountPath": v.mount_path, "mode": v.mode.value}
@@ -602,3 +651,117 @@ class DispatchConfig(BaseModel):
602
651
  return result
603
652
 
604
653
  model_config = {"populate_by_name": True}
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Runtime config singleton
658
+ # ---------------------------------------------------------------------------
659
+
660
+ import functools as _functools
661
+
662
+ _DISPATCH_YAML_PATH = "/app/dispatch.yaml"
663
+
664
+
665
+ @_functools.lru_cache(maxsize=1)
666
+ def _load_runtime_config() -> "DispatchConfig":
667
+ """Load dispatch.yaml once and cache the parsed DispatchConfig.
668
+
669
+ Falls back to an empty DispatchConfig when the file is absent
670
+ (dev mode or non-containerized runs). Namespace and agent_name
671
+ additionally fall back to DISPATCH_NAMESPACE / DISPATCH_AGENT_NAME
672
+ env vars so local ``dispatch agent dev`` runs work transparently.
673
+ """
674
+ import yaml as _yaml
675
+
676
+ path = os.environ.get("DISPATCH_CONFIG_PATH", _DISPATCH_YAML_PATH)
677
+ raw: dict = {}
678
+ if os.path.exists(path):
679
+ with open(path, encoding="utf-8") as f:
680
+ raw = _yaml.safe_load(f) or {}
681
+
682
+ cfg = DispatchConfig.model_validate(raw)
683
+
684
+ # Env-var fallbacks for identity fields used outside containers
685
+ updates: dict[str, Any] = {}
686
+ if cfg.namespace is None:
687
+ ns = os.environ.get("DISPATCH_NAMESPACE")
688
+ if ns:
689
+ updates["namespace"] = ns
690
+ if cfg.agent_name is None:
691
+ name = os.environ.get("DISPATCH_AGENT_NAME")
692
+ if name:
693
+ updates["agent_name"] = name
694
+ if updates:
695
+ cfg = cfg.model_copy(update=updates)
696
+
697
+ return cfg
698
+
699
+
700
+ _DESCRIBED_VAR_KEYS = frozenset({"value", "description"})
701
+
702
+
703
+ def _is_described_var(val: Any) -> bool:
704
+ """Check if a value is a described var ({value, description}) vs a plain dict."""
705
+ return (
706
+ isinstance(val, dict)
707
+ and "value" in val
708
+ and set(val.keys()) <= _DESCRIBED_VAR_KEYS
709
+ )
710
+
711
+
712
+ def _unwrap_vars(raw: dict[str, Any] | None) -> dict[str, Any]:
713
+ """Unwrap described vars ({value, description}) to plain values.
714
+
715
+ Plain dicts (with keys outside {value, description}) are passed through as-is.
716
+ """
717
+ if not raw:
718
+ return {}
719
+ result: dict[str, Any] = {}
720
+ for key, val in raw.items():
721
+ if _is_described_var(val):
722
+ result[key] = val["value"]
723
+ else:
724
+ result[key] = val
725
+ return result
726
+
727
+
728
+ def _extract_var_descriptions(raw: dict[str, Any] | None) -> dict[str, str]:
729
+ """Extract description strings from described vars."""
730
+ if not raw:
731
+ return {}
732
+ result: dict[str, str] = {}
733
+ for key, val in raw.items():
734
+ if _is_described_var(val) and "description" in val:
735
+ result[key] = val["description"]
736
+ return result
737
+
738
+
739
+ class _RuntimeConfig:
740
+ """Proxy that exposes dispatch.yaml fields at runtime.
741
+
742
+ Usage::
743
+
744
+ from dispatch_agents import config
745
+
746
+ config.namespace # str | None
747
+ config.agent_name # str | None
748
+ config.vars["temperature"] # 0.7 (unwrapped)
749
+ config.vars.get("missing") # None
750
+ config.vars_descriptions["max_turns"] # "Maximum agent turns"
751
+ """
752
+
753
+ @property
754
+ def vars(self) -> dict[str, Any]:
755
+ """Return unwrapped vars dict (described vars return just the value)."""
756
+ return _unwrap_vars(_load_runtime_config().vars)
757
+
758
+ @property
759
+ def vars_descriptions(self) -> dict[str, str]:
760
+ """Return descriptions for vars that have them."""
761
+ return _extract_var_descriptions(_load_runtime_config().vars)
762
+
763
+ def __getattr__(self, name: str) -> Any:
764
+ return getattr(_load_runtime_config(), name)
765
+
766
+
767
+ _runtime = _RuntimeConfig()
@@ -83,9 +83,9 @@ GitHub Topics:
83
83
 
84
84
  from __future__ import annotations
85
85
 
86
- from typing import Any, ClassVar, Literal
86
+ from typing import Any, ClassVar, Literal, Self
87
87
 
88
- from pydantic import ConfigDict, Field
88
+ from pydantic import ConfigDict, Field, StrictInt, model_validator
89
89
 
90
90
  from dispatch_agents.events import BasePayload
91
91
  from dispatch_agents.integrations.github.client import GitHubAppToken
@@ -209,12 +209,12 @@ class GitHubRepository(GitHubModel):
209
209
  description: str | None = Field(default=None, description="Repository description")
210
210
  fork: bool = Field(default=False, description="Whether this is a fork")
211
211
  url: str | None = Field(default=None, description="API URL")
212
- created_at: str | None = Field(
213
- default=None, description="ISO8601 creation timestamp"
212
+ created_at: StrictInt | str = Field(
213
+ description="Creation timestamp (Unix epoch int or ISO8601 string)",
214
214
  )
215
215
  updated_at: str | None = Field(default=None, description="ISO8601 update timestamp")
216
- pushed_at: str | None = Field(
217
- default=None, description="ISO8601 last push timestamp"
216
+ pushed_at: StrictInt | str | None = Field(
217
+ description="Last push timestamp (Unix epoch int, ISO8601 string, or null)",
218
218
  )
219
219
  homepage: str | None = Field(default=None, description="Homepage URL")
220
220
  size: int = Field(default=0, description="Repository size in KB")
@@ -490,7 +490,7 @@ class GitHubCommitUser(GitHubModel):
490
490
  """Git user information (author/committer)."""
491
491
 
492
492
  name: str = Field(description="Git user name")
493
- email: str = Field(description="Git user email")
493
+ email: str | None = Field(description="Git user email")
494
494
  username: str | None = Field(default=None, description="GitHub username if linked")
495
495
  date: str | None = Field(default=None, description="ISO8601 timestamp")
496
496
 
@@ -906,20 +906,38 @@ class PullRequestUnassigned(PullRequestBase):
906
906
  assignee: GitHubUser = Field(description="User who was unassigned")
907
907
 
908
908
 
909
- class PullRequestReviewRequested(PullRequestBase):
909
+ class PullRequestReviewTargetBase(PullRequestBase):
910
+ """Base payload for pull_request review target events."""
911
+
912
+ requested_reviewer: GitHubUser | None = Field(
913
+ default=None,
914
+ description="User requested for review (absent when a team is requested)",
915
+ )
916
+ requested_team: GitHubTeam | None = Field(
917
+ default=None,
918
+ description="Team requested for review (absent when a user is requested)",
919
+ )
920
+
921
+ @model_validator(mode="after")
922
+ def validate_review_target(self) -> Self:
923
+ """Require exactly one review target field."""
924
+ if (self.requested_reviewer is None) == (self.requested_team is None):
925
+ raise ValueError(
926
+ "Exactly one of requested_reviewer or requested_team must be provided"
927
+ )
928
+ return self
929
+
930
+
931
+ class PullRequestReviewRequested(PullRequestReviewTargetBase):
910
932
  """Payload for github.pull_request.review_requested events."""
911
933
 
912
934
  _dispatch_topic: ClassVar[str] = "github.pull_request.review_requested"
913
- requested_reviewer: GitHubUser = Field(description="User requested for review")
914
935
 
915
936
 
916
- class PullRequestReviewRequestRemoved(PullRequestBase):
937
+ class PullRequestReviewRequestRemoved(PullRequestReviewTargetBase):
917
938
  """Payload for github.pull_request.review_request_removed events."""
918
939
 
919
940
  _dispatch_topic: ClassVar[str] = "github.pull_request.review_request_removed"
920
- requested_reviewer: GitHubUser = Field(
921
- description="User whose review request was removed"
922
- )
923
941
 
924
942
 
925
943
  class PullRequestReadyForReview(PullRequestBase):
@@ -19,6 +19,13 @@ from typing import Annotated, Any, Literal, TypeAlias
19
19
 
20
20
  from pydantic import BaseModel, ConfigDict, Field, field_validator
21
21
 
22
+ _IDENTIFIER_PATTERN = r"^[a-zA-Z0-9._-]+$"
23
+ _IDENTIFIER_MAX_LENGTH = 128
24
+ Identifier: TypeAlias = Annotated[
25
+ str,
26
+ Field(min_length=1, max_length=_IDENTIFIER_MAX_LENGTH, pattern=_IDENTIFIER_PATTERN),
27
+ ]
28
+
22
29
 
23
30
  def get_now_utc() -> str:
24
31
  """Get the current UTC time in ISO8601 format."""
@@ -391,7 +398,7 @@ class AgentFunction(StrictBaseModel):
391
398
  the triggers that can invoke it (topics, schedules, etc.).
392
399
  """
393
400
 
394
- name: str = Field(description="Handler function name")
401
+ name: Identifier = Field(description="Handler function name")
395
402
  description: str | None = Field(
396
403
  default=None, description="Handler docstring or description"
397
404
  )
@@ -627,9 +634,9 @@ class PublishEventBody(StrictBaseModel):
627
634
  class SubscriptionBody(StrictBaseModel):
628
635
  """Request body for agent subscription management."""
629
636
 
630
- topics: list[str]
631
- agent_name: str
632
- functions: list[AgentFunction] | None = None
637
+ topics: list[Identifier] = Field(default_factory=list)
638
+ agent_name: Identifier
639
+ functions: list[AgentFunction] = Field(min_length=1)
633
640
 
634
641
 
635
642
  class EventRequest(StrictBaseModel):
@@ -1,5 +1,4 @@
1
1
  namespace: examples
2
2
  entrypoint: agent.py
3
- base_image: python:3.13-slim
4
3
  system_packages: []
5
4
  agent_name: company-researcher
@@ -1,5 +1,4 @@
1
1
  namespace: examples
2
2
  entrypoint: agent.py
3
- base_image: python:3.13-slim
4
3
  system_packages: []
5
4
  agent_name: conversational-agent
@@ -1,5 +1,4 @@
1
1
  namespace: examples
2
2
  entrypoint: agent.py
3
- base_image: python:3.13-slim
4
3
  system_packages: []
5
4
  agent_name: daily-digest
@@ -1,6 +1,5 @@
1
1
  namespace: examples
2
2
  entrypoint: agent.py
3
- base_image: python:3.13-slim
4
3
  system_packages: []
5
4
  agent_name: deep-research
6
5
  secrets:
@@ -1,13 +1,20 @@
1
1
  """Generated agent entrypoint."""
2
2
 
3
3
  import asyncio
4
+ import logging
5
+ import os
4
6
 
5
7
  import aiohttp
6
8
  import dispatch_agents
7
9
  from dispatch_agents import BasePayload, fn, on
8
- from dispatch_agents.integrations.github import PullRequestReviewCommentCreated
10
+ from dispatch_agents.integrations.github import (
11
+ CheckSuiteCompleted,
12
+ PullRequestReviewCommentCreated,
13
+ )
9
14
  from pydantic import Field, PositiveInt
10
15
 
16
+ logger = logging.getLogger(__name__)
17
+
11
18
 
12
19
  class GreetingPayload(BasePayload):
13
20
  """Input payload for greeting requests."""
@@ -30,7 +37,7 @@ async def greet(payload: GreetingPayload) -> GreetingResponse:
30
37
  - Typed output serialization (returns GreetingResponse)
31
38
  - Automatic schema extraction for API documentation
32
39
  """
33
- print(f"Handling greet request for: {payload.subject}")
40
+ logger.info("Handling greet request for: %s", payload.subject)
34
41
 
35
42
  # Validate that subject field exists - ValueError is non-retryable
36
43
  if not payload.subject:
@@ -60,13 +67,13 @@ class SleepResponse(BasePayload):
60
67
  @dispatch_agents.on(topic="sleep")
61
68
  async def sleep(payload: SleepRequest) -> SleepResponse:
62
69
  """Sleep for the specified duration, logging countdown progress."""
63
- print(f"Starting sleep for {payload.duration_seconds} seconds")
70
+ logger.info("Starting sleep for %s seconds", payload.duration_seconds)
64
71
 
65
72
  for remaining in range(payload.duration_seconds, 0, -1):
66
- print(f"Countdown: {remaining} seconds remaining")
73
+ logger.info("Countdown: %s seconds remaining", remaining)
67
74
  await asyncio.sleep(1)
68
75
 
69
- print("Sleep completed")
76
+ logger.info("Sleep completed")
70
77
  return SleepResponse(seconds_slept=payload.duration_seconds)
71
78
 
72
79
 
@@ -83,9 +90,9 @@ async def on_pr_review_comment(
83
90
  event: PullRequestReviewCommentCreated,
84
91
  ) -> PRReviewCommentResponse:
85
92
  """Handle GitHub PR review comment created events."""
86
- print(f"Received PR review comment from {event.comment.user.login}")
87
- print(f"Comment body: {event.comment.body[:100]}...")
88
- print(f"PR: {event.pull_request.title}")
93
+ logger.info("Received PR review comment from %s", event.comment.user.login)
94
+ logger.info("Comment body: %.100s...", event.comment.body)
95
+ logger.info("PR: %s", event.pull_request.title)
89
96
 
90
97
  return PRReviewCommentResponse(
91
98
  repo=event.repository.full_name,
@@ -94,6 +101,34 @@ async def on_pr_review_comment(
94
101
  )
95
102
 
96
103
 
104
+ class CheckSuiteCompletedResponse(BasePayload):
105
+ """Response for check_suite.completed events."""
106
+
107
+ repo: str | None = Field(description="Repository full name (owner/repo)")
108
+ head_sha: str = Field(description="Head commit SHA of the suite")
109
+ conclusion: str | None = Field(
110
+ description="Suite conclusion (success, failure, ...)"
111
+ )
112
+
113
+
114
+ @on(github_event=CheckSuiteCompleted)
115
+ async def on_check_suite_completed(
116
+ event: CheckSuiteCompleted,
117
+ ) -> CheckSuiteCompletedResponse:
118
+ """Handle GitHub check_suite.completed events."""
119
+ logger.info(
120
+ "Check suite completed: repo=%s sha=%s conclusion=%s",
121
+ event.repository.full_name if event.repository else None,
122
+ event.check_suite.head_sha,
123
+ event.check_suite.conclusion,
124
+ )
125
+ return CheckSuiteCompletedResponse(
126
+ repo=event.repository.full_name if event.repository else None,
127
+ head_sha=event.check_suite.head_sha,
128
+ conclusion=event.check_suite.conclusion,
129
+ )
130
+
131
+
97
132
  class ReverseRequest(BasePayload):
98
133
  """Input for the reverse function."""
99
134
 
@@ -109,10 +144,68 @@ class ReverseResponse(BasePayload):
109
144
  @fn()
110
145
  async def reverse(payload: ReverseRequest) -> ReverseResponse:
111
146
  """Reverse the provided text string."""
112
- print(f"Reversing: {payload.text!r}")
147
+ logger.info("Reversing: %r", payload.text)
113
148
  return ReverseResponse(reversed_text=payload.text[::-1])
114
149
 
115
150
 
151
+ class StorageWriteRequest(BasePayload):
152
+ """Input for writing to persistent storage."""
153
+
154
+ key: str = Field(description="Filename to write")
155
+ value: str = Field(description="Content to write")
156
+
157
+
158
+ class StorageWriteResponse(BasePayload):
159
+ """Output of storage write."""
160
+
161
+ path: str = Field(description="Full path of the written file")
162
+
163
+
164
+ def _safe_data_path(key: str) -> str:
165
+ """Resolve a key to a path under /data, rejecting traversal attempts."""
166
+ path = os.path.abspath(os.path.join("/data", key))
167
+ if not path.startswith("/data/"):
168
+ raise ValueError("Invalid key: must resolve within /data")
169
+ return path
170
+
171
+
172
+ @fn()
173
+ async def storage_write(payload: StorageWriteRequest) -> StorageWriteResponse:
174
+ """Write a value to persistent storage at /data."""
175
+ path = _safe_data_path(payload.key)
176
+ os.makedirs(os.path.dirname(path), exist_ok=True)
177
+ with open(path, "w") as f:
178
+ f.write(payload.value)
179
+ logger.info("Wrote %s bytes to %s", len(payload.value), path)
180
+ return StorageWriteResponse(path=path)
181
+
182
+
183
+ class StorageReadRequest(BasePayload):
184
+ """Input for reading from persistent storage."""
185
+
186
+ key: str = Field(description="Filename to read")
187
+
188
+
189
+ class StorageReadResponse(BasePayload):
190
+ """Output of storage read."""
191
+
192
+ value: str | None = Field(description="File content, or null if not found")
193
+ exists: bool = Field(description="Whether the file exists")
194
+
195
+
196
+ @fn()
197
+ async def storage_read(payload: StorageReadRequest) -> StorageReadResponse:
198
+ """Read a value from persistent storage at /data."""
199
+ path = _safe_data_path(payload.key)
200
+ if os.path.exists(path):
201
+ with open(path) as f:
202
+ value = f.read()
203
+ logger.info("Read %s bytes from %s", len(value), path)
204
+ return StorageReadResponse(value=value, exists=True)
205
+ logger.info("File not found: %s", path)
206
+ return StorageReadResponse(value=None, exists=False)
207
+
208
+
116
209
  class EgressTestRequest(BasePayload):
117
210
  """Input for the egress test function."""
118
211
 
@@ -140,21 +233,21 @@ async def test_egress(payload: EgressTestRequest) -> EgressTestResponse:
140
233
  configured, this request will be blocked unless the target domain is
141
234
  in allow_domains.
142
235
  """
143
- print(f"Testing egress to: {payload.url}")
236
+ logger.info("Testing egress to: %s", payload.url)
144
237
  try:
145
238
  async with aiohttp.ClientSession(
146
239
  timeout=aiohttp.ClientTimeout(total=10)
147
240
  ) as session:
148
241
  async with session.get(payload.url) as resp:
149
242
  body = await resp.text()
150
- print(f"Response: {resp.status} ({len(body)} bytes)")
243
+ logger.info("Response: %s (%s bytes)", resp.status, len(body))
151
244
  return EgressTestResponse(
152
245
  success=True,
153
246
  status_code=resp.status,
154
247
  body=body[:1000],
155
248
  )
156
249
  except Exception as e:
157
- print(f"Request failed: {type(e).__name__}: {e}")
250
+ logger.info("Request failed: %s: %s", type(e).__name__, e)
158
251
  return EgressTestResponse(
159
252
  success=False,
160
253
  body=f"{type(e).__name__}: {e}",
@@ -0,0 +1,16 @@
1
+ namespace: examples
2
+ entrypoint: agent.py
3
+ system_packages: []
4
+ agent_name: hello-world
5
+ volumes:
6
+ - name: data
7
+ mountPath: /data
8
+ mode: read_write_many
9
+ resources:
10
+ limits:
11
+ cpu: 250m
12
+ memory: 2Gi
13
+ network:
14
+ egress:
15
+ allow_domains:
16
+ - match_name: jsonplaceholder.typicode.com