dispatch_agents 0.9.0__py3-none-any.whl

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 (43) hide show
  1. agentservice/__init__.py +0 -0
  2. agentservice/py.typed +0 -0
  3. agentservice/v1/__init__.py +0 -0
  4. agentservice/v1/message_pb2.py +41 -0
  5. agentservice/v1/message_pb2.pyi +22 -0
  6. agentservice/v1/message_pb2_grpc.py +4 -0
  7. agentservice/v1/request_response_pb2.py +46 -0
  8. agentservice/v1/request_response_pb2.pyi +54 -0
  9. agentservice/v1/request_response_pb2_grpc.py +4 -0
  10. agentservice/v1/service_pb2.py +43 -0
  11. agentservice/v1/service_pb2.pyi +6 -0
  12. agentservice/v1/service_pb2_grpc.py +129 -0
  13. dispatch_agents/__init__.py +281 -0
  14. dispatch_agents/agent_service.py +135 -0
  15. dispatch_agents/config.py +490 -0
  16. dispatch_agents/contrib/__init__.py +1 -0
  17. dispatch_agents/contrib/claude/__init__.py +246 -0
  18. dispatch_agents/contrib/openai/__init__.py +167 -0
  19. dispatch_agents/events.py +986 -0
  20. dispatch_agents/grpc_server.py +565 -0
  21. dispatch_agents/instrument.py +217 -0
  22. dispatch_agents/integrations/__init__.py +1 -0
  23. dispatch_agents/integrations/github/README.md +9 -0
  24. dispatch_agents/integrations/github/__init__.py +4268 -0
  25. dispatch_agents/invocation.py +25 -0
  26. dispatch_agents/llm.py +1017 -0
  27. dispatch_agents/llm_langchain.py +394 -0
  28. dispatch_agents/logging_config.py +133 -0
  29. dispatch_agents/mcp.py +266 -0
  30. dispatch_agents/memory.py +264 -0
  31. dispatch_agents/models.py +748 -0
  32. dispatch_agents/proxy/__init__.py +6 -0
  33. dispatch_agents/proxy/server.py +1137 -0
  34. dispatch_agents/proxy/sse_utils.py +76 -0
  35. dispatch_agents/py.typed +0 -0
  36. dispatch_agents/resources.py +68 -0
  37. dispatch_agents/version.py +19 -0
  38. dispatch_agents-0.9.0.dist-info/METADATA +20 -0
  39. dispatch_agents-0.9.0.dist-info/RECORD +43 -0
  40. dispatch_agents-0.9.0.dist-info/WHEEL +4 -0
  41. dispatch_agents-0.9.0.dist-info/licenses/LICENSE +191 -0
  42. dispatch_agents-0.9.0.dist-info/licenses/LICENSE-3rdparty.csv +12 -0
  43. dispatch_agents-0.9.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,748 @@
1
+ """Core models for the Dispatch SDK.
2
+
3
+ This module contains the fundamental data structures used across the entire
4
+ Dispatch ecosystem, including Message types for universal communication and Agent
5
+ for service registration and management.
6
+
7
+ Message Type Hierarchy (similar to LangChain's BaseMessage pattern):
8
+ - BaseMessage: Abstract base with common fields (uid, trace_id, sender_id, ts, payload)
9
+ - TopicMessage: For @on topic handlers (has 'topic' field)
10
+ - FunctionMessage: For @fn direct calls (has 'function_name' field)
11
+ - ScheduleMessage: For scheduled/cron triggers (has 'schedule_name' field)
12
+ - Message: Discriminated union type alias for routing
13
+ """
14
+
15
+ import uuid
16
+ from datetime import UTC, datetime
17
+ from enum import StrEnum, auto
18
+ from typing import Annotated, Any, Literal, TypeAlias
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
21
+
22
+
23
+ def get_now_utc() -> str:
24
+ """Get the current UTC time in ISO8601 format."""
25
+ return datetime.now(UTC).isoformat()
26
+
27
+
28
+ JsonSchema: TypeAlias = dict[str, Any]
29
+ """A JSON Schema document, e.g. from Pydantic's model_json_schema()."""
30
+
31
+ # =============================================================================
32
+ # Feedback Types - Shared across backend, CLI, and SDK
33
+ # =============================================================================
34
+
35
+ FeedbackType: TypeAlias = Literal["bug", "feature_request", "general"]
36
+ """Type of customer feedback submission."""
37
+
38
+ FeedbackSentiment: TypeAlias = Literal["positive", "negative"]
39
+ """Thumbs up/down sentiment for feedback."""
40
+
41
+
42
+ class StrictBaseModel(BaseModel):
43
+ """Base model with strict validation that forbids extra fields.
44
+
45
+ All Dispatch models inherit from this to ensure API compatibility
46
+ and catch typos in field names at validation time.
47
+ """
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+
52
+ # =============================================================================
53
+ # Message Types - Discriminated Union Pattern
54
+ # =============================================================================
55
+ # Following LangChain's BaseMessage pattern with Pydantic discriminated unions.
56
+ # The 'type' field acts as discriminator - Pydantic routes to correct subclass
57
+ # BEFORE validation, so strict validation (extra='forbid') works correctly.
58
+ # =============================================================================
59
+
60
+
61
+ class BaseMessage(StrictBaseModel):
62
+ """Abstract base class for all dispatch messages.
63
+
64
+ Similar to LangChain's BaseMessage pattern. Contains common fields
65
+ shared across all message types. Should not be instantiated directly -
66
+ use TopicMessage, FunctionMessage, or ScheduleMessage instead.
67
+
68
+ | Field | Type | Required | Notes |
69
+ | ---------- | ------ | -------- | -------------------------------------------------------------------- |
70
+ | type | string | always | Discriminator: "topic", "function", or "schedule" |
71
+ | payload | object | always | Business data, validated against registered input model |
72
+ | uid | string | always | Unique ID per message |
73
+ | trace_id | string | always | Groups related messages into a workflow/session |
74
+ | sender_id | string | always | ID of the sending agent/tool |
75
+ | ts | string | always | ISO8601 timestamp of when message was created |
76
+ | parent_id | string | optional | UID of parent message for building trace trees (None for root events)|
77
+ """
78
+
79
+ type: str # Abstract - subclasses override with Literal
80
+ payload: Any
81
+ uid: str
82
+ trace_id: str
83
+ sender_id: str
84
+ ts: str
85
+ parent_id: str | None = None
86
+
87
+
88
+ class TopicMessage(BaseMessage):
89
+ """Message routed by topic subscription (@on decorator).
90
+
91
+ Used for event-driven communication where agents subscribe to topics
92
+ and receive messages published to those topics.
93
+ """
94
+
95
+ type: Literal["topic"] = "topic"
96
+ topic: str = Field(description="Event topic for subscription-based routing")
97
+
98
+ @classmethod
99
+ def create(
100
+ cls,
101
+ topic: str,
102
+ payload: Any,
103
+ sender_id: str,
104
+ trace_id: str | None = None,
105
+ parent_id: str | None = None,
106
+ _uid: str | None = None,
107
+ _ts: str | None = None,
108
+ ) -> "TopicMessage":
109
+ """Create a new TopicMessage with auto-generated fields."""
110
+ return cls(
111
+ topic=topic,
112
+ payload=payload,
113
+ sender_id=sender_id,
114
+ trace_id=trace_id or str(uuid.uuid4()),
115
+ parent_id=parent_id,
116
+ uid=_uid or str(uuid.uuid4()),
117
+ ts=_ts or get_now_utc(),
118
+ )
119
+
120
+
121
+ class FunctionMessage(BaseMessage):
122
+ """Message for direct function invocation (@fn decorator).
123
+
124
+ Used when one agent calls a function on another agent directly,
125
+ bypassing topic-based routing.
126
+ """
127
+
128
+ type: Literal["function"] = "function"
129
+ function_name: str = Field(description="Name of the @fn function to invoke")
130
+
131
+ @classmethod
132
+ def create(
133
+ cls,
134
+ function_name: str,
135
+ payload: Any,
136
+ sender_id: str,
137
+ trace_id: str | None = None,
138
+ parent_id: str | None = None,
139
+ _uid: str | None = None,
140
+ _ts: str | None = None,
141
+ ) -> "FunctionMessage":
142
+ """Create a new FunctionMessage with auto-generated fields."""
143
+ return cls(
144
+ type="function",
145
+ function_name=function_name,
146
+ payload=payload,
147
+ sender_id=sender_id,
148
+ trace_id=trace_id or str(uuid.uuid4()),
149
+ parent_id=parent_id,
150
+ uid=_uid or str(uuid.uuid4()),
151
+ ts=_ts or get_now_utc(),
152
+ )
153
+
154
+
155
+ class ScheduleMessage(BaseMessage):
156
+ """Message triggered by a schedule/cron job.
157
+
158
+ Used for time-based triggers where the system invokes an agent
159
+ function based on a schedule configuration.
160
+ """
161
+
162
+ type: Literal["schedule"] = "schedule"
163
+ schedule_name: str = Field(description="Schedule ID that triggered this invocation")
164
+ function_name: str = Field(description="Name of the function being invoked")
165
+
166
+ @classmethod
167
+ def create(
168
+ cls,
169
+ schedule_name: str,
170
+ function_name: str,
171
+ payload: Any,
172
+ sender_id: str,
173
+ trace_id: str | None = None,
174
+ parent_id: str | None = None,
175
+ _uid: str | None = None,
176
+ _ts: str | None = None,
177
+ ) -> "ScheduleMessage":
178
+ """Create a new ScheduleMessage with auto-generated fields."""
179
+ return cls(
180
+ type="schedule",
181
+ schedule_name=schedule_name,
182
+ function_name=function_name,
183
+ payload=payload,
184
+ sender_id=sender_id,
185
+ trace_id=trace_id or str(uuid.uuid4()),
186
+ parent_id=parent_id,
187
+ uid=_uid or str(uuid.uuid4()),
188
+ ts=_ts or get_now_utc(),
189
+ )
190
+
191
+
192
+ class LLMCallMessage(BaseMessage):
193
+ """Message representing an LLM inference call.
194
+
195
+ Used to track LLM calls within a trace, enabling unified trace views
196
+ that show LLM interactions alongside invocations and topic messages.
197
+
198
+ The parent_id field can point to:
199
+ - An invocation UID: The LLM call was made by this invocation
200
+ - Another LLM call UID: The LLM call is a continuation (e.g., after tool execution)
201
+
202
+ Children of this LLM call (invocations with parent_id pointing here) represent
203
+ tool call results - invocations triggered by the LLM's tool_calls response.
204
+ """
205
+
206
+ type: Literal["llm_call"] = "llm_call"
207
+
208
+ # LLM-specific fields
209
+ model: str = Field(description="Model used (e.g., gpt-4o, claude-3-5-sonnet)")
210
+ provider: str = Field(description="Provider (e.g., openai, anthropic)")
211
+ messages: list[dict[str, Any]] = Field(description="Request messages sent to LLM")
212
+ response: str | None = Field(default=None, description="LLM response text")
213
+ finish_reason: str = Field(
214
+ description="Completion reason: stop, tool_calls, length, etc."
215
+ )
216
+
217
+ # Usage metrics
218
+ input_tokens: int = Field(description="Prompt token count")
219
+ output_tokens: int = Field(description="Completion token count")
220
+ cost_usd: float = Field(description="Calculated cost in USD")
221
+ latency_ms: int = Field(description="Response time in milliseconds")
222
+
223
+ # Optional fields
224
+ tools: list[dict[str, Any]] | None = Field(
225
+ default=None, description="Tool definitions if function calling was used"
226
+ )
227
+ tool_calls: list[dict[str, Any]] | None = Field(
228
+ default=None, description="Tool calls from response"
229
+ )
230
+ variant_name: str | None = Field(
231
+ default=None, description="A/B test variant if applicable"
232
+ )
233
+
234
+ @classmethod
235
+ def create(
236
+ cls,
237
+ *,
238
+ model: str,
239
+ provider: str,
240
+ messages: list[dict[str, Any]],
241
+ finish_reason: str,
242
+ input_tokens: int,
243
+ output_tokens: int,
244
+ cost_usd: float,
245
+ latency_ms: int,
246
+ sender_id: str,
247
+ trace_id: str,
248
+ parent_id: str | None = None,
249
+ response: str | None = None,
250
+ tools: list[dict[str, Any]] | None = None,
251
+ tool_calls: list[dict[str, Any]] | None = None,
252
+ variant_name: str | None = None,
253
+ _uid: str | None = None,
254
+ _ts: str | None = None,
255
+ ) -> "LLMCallMessage":
256
+ """Create a new LLMCallMessage with auto-generated fields.
257
+
258
+ Args:
259
+ model: Model used for inference
260
+ provider: LLM provider
261
+ messages: Request messages
262
+ finish_reason: Why the LLM stopped generating
263
+ input_tokens: Number of input tokens
264
+ output_tokens: Number of output tokens
265
+ cost_usd: Cost in USD
266
+ latency_ms: Latency in milliseconds
267
+ sender_id: Agent/caller that made this LLM call
268
+ trace_id: Trace ID for correlation
269
+ parent_id: Parent invocation or LLM call UID
270
+ response: LLM response text
271
+ tools: Tool definitions
272
+ tool_calls: Tool calls from response
273
+ variant_name: A/B test variant
274
+ _uid: Override UID (auto-generated if not provided)
275
+ _ts: Override timestamp
276
+ """
277
+ uid = _uid or str(uuid.uuid4())
278
+ return cls(
279
+ type="llm_call",
280
+ model=model,
281
+ provider=provider,
282
+ messages=messages,
283
+ finish_reason=finish_reason,
284
+ input_tokens=input_tokens,
285
+ output_tokens=output_tokens,
286
+ cost_usd=cost_usd,
287
+ latency_ms=latency_ms,
288
+ sender_id=sender_id,
289
+ trace_id=trace_id,
290
+ parent_id=parent_id,
291
+ response=response,
292
+ tools=tools,
293
+ tool_calls=tool_calls,
294
+ variant_name=variant_name,
295
+ # BaseMessage fields - payload is empty for LLM calls (data is in specific fields)
296
+ payload={},
297
+ uid=uid,
298
+ ts=_ts or get_now_utc(),
299
+ )
300
+
301
+
302
+ # Discriminated union - Pydantic routes by 'type' field before validation
303
+ # This means strict validation (extra='forbid') works correctly with subclass fields
304
+ Message = Annotated[
305
+ TopicMessage | FunctionMessage | ScheduleMessage | LLMCallMessage,
306
+ Field(discriminator="type"),
307
+ ]
308
+
309
+
310
+ # =============================================================================
311
+ # Handler Response Payloads
312
+ # =============================================================================
313
+ # These are serialized into the gRPC InvokeResponse.result field.
314
+ # The proto's is_error bool tells the backend which type to deserialize to.
315
+ # =============================================================================
316
+
317
+
318
+ class SuccessPayload(StrictBaseModel):
319
+ """Successful handler execution result.
320
+
321
+ Serialized into InvokeResponse.result when is_error=False.
322
+ """
323
+
324
+ result: Any = Field(
325
+ description="Handler return value (any JSON-serializable value)"
326
+ )
327
+
328
+
329
+ class ErrorPayload(StrictBaseModel):
330
+ """Failed handler execution result.
331
+
332
+ Serialized into InvokeResponse.result when is_error=True.
333
+ Contains structured error information for debugging and display.
334
+ """
335
+
336
+ error: str = Field(description="Error message")
337
+ error_type: str = Field(
338
+ description="Exception class name (e.g., 'ValidationError', 'ValueError')"
339
+ )
340
+ trace: str | None = Field(default=None, description="Full traceback for debugging")
341
+ details: Any | None = Field(
342
+ default=None,
343
+ description="Additional error details (e.g., Pydantic validation errors list)",
344
+ )
345
+
346
+
347
+ class AgentContainerStatus(StrEnum):
348
+ BUILDING = auto() # agent created/updated when codebuild starts
349
+ DEPLOYING = auto() # image built, ecs rolling update in progress
350
+ DEPLOYED = auto() # ip/dns alias available from ecs, ready to be health-checked
351
+ ERROR = auto() # ecs task failed to launch/retrying
352
+ HEALTHY = auto() # passed health check from in-memory registry
353
+ UNHEALTHY = (
354
+ auto()
355
+ ) # deployed agent went from healthy->unhealthy or never passed health check
356
+ DISABLED = (
357
+ auto()
358
+ ) # intentionally stopped/disabled, will not be considered for health check
359
+
360
+
361
+ class FunctionTrigger(StrictBaseModel):
362
+ """Trigger configuration for an agent function.
363
+
364
+ Defines how and when an agent function is invoked. Currently supports:
365
+ - topic: Event-driven triggers from subscribed topics
366
+ - callable: Direct function calls from other agents via invoke()
367
+ - schedule: Time-based triggers (future)
368
+ """
369
+
370
+ type: Literal["topic", "schedule", "callable"] = Field(
371
+ description="Trigger type: 'topic' for event-driven, 'callable' for direct invocation, 'schedule' for cron-based"
372
+ )
373
+ topic: str | None = Field(
374
+ default=None,
375
+ description="Topic name when type='topic'. Events published to this topic will invoke the function.",
376
+ )
377
+ function_name: str | None = Field(
378
+ default=None,
379
+ description="Function name when type='callable'. Other agents can invoke this function by name.",
380
+ )
381
+ # Future fields for schedule triggers:
382
+ # cron_expression: str | None - Cron syntax for schedule
383
+ # timezone: str | None - Timezone for schedule interpretation
384
+ # enabled: bool - Whether schedule is active
385
+
386
+
387
+ class AgentFunction(StrictBaseModel):
388
+ """Agent function with input/output schemas and triggers.
389
+
390
+ Represents a handler function in an agent, including its schemas and
391
+ the triggers that can invoke it (topics, schedules, etc.).
392
+ """
393
+
394
+ name: str = Field(description="Handler function name")
395
+ description: str | None = Field(
396
+ default=None, description="Handler docstring or description"
397
+ )
398
+ input_schema: JsonSchema = Field(
399
+ description="JSON Schema for input payload validation"
400
+ )
401
+ output_schema: JsonSchema | None = Field(
402
+ default=None, description="JSON Schema for output payload (if any)"
403
+ )
404
+ triggers: list[FunctionTrigger] = Field(
405
+ default_factory=list, description="Triggers that invoke this function"
406
+ )
407
+
408
+
409
+ class Agent(StrictBaseModel):
410
+ """Agent registration and metadata model.
411
+
412
+ Uses composite uid (org_id#namespace#name) as unique identifier.
413
+ The name field stores the ECS-sanitized agent name.
414
+ """
415
+
416
+ # === Persistent fields (stored in DynamoDB) ===
417
+ name: str = Field(
418
+ description="ECS-sanitized agent name (used in uid composite key)"
419
+ )
420
+ org_id: str = Field(description="Organization ID for multi-tenancy")
421
+ namespace: str = Field(description="Namespace for logical isolation within org")
422
+ status: AgentContainerStatus = Field(
423
+ default=AgentContainerStatus.BUILDING,
424
+ description="Agent deployment/health status",
425
+ )
426
+ created_at: str = Field(description="ISO8601 timestamp when agent was created")
427
+ last_updated: str = Field(
428
+ description="ISO8601 timestamp when DB record was updated"
429
+ )
430
+ url: str | None = Field(default=None, description="Agent DNS alias endpoint URL")
431
+ version: str | None = Field(
432
+ default=None, description="Current deployed version from S3"
433
+ )
434
+ last_deployed: str | None = Field(
435
+ default=None,
436
+ description="ISO8601 timestamp of last successful deployment",
437
+ )
438
+ monthly_budget_usd: float | None = Field(
439
+ default=None,
440
+ description="Monthly LLM spend limit in USD. If set, inference requests "
441
+ "are blocked when the agent exceeds this amount in the current month.",
442
+ )
443
+
444
+ # === Runtime fields (in-memory only, not persisted to DynamoDB) ===
445
+ functions: list[AgentFunction] = Field(
446
+ default_factory=list,
447
+ description="Agent functions with their triggers, schemas, and metadata. "
448
+ "Each function can have multiple triggers (topics, schedules, etc.).",
449
+ )
450
+ last_heartbeat: str | None = Field(
451
+ default=None, description="ISO8601 timestamp of last heartbeat"
452
+ )
453
+ metadata: dict[str, Any] = Field(
454
+ default_factory=dict, description="Additional runtime metadata"
455
+ )
456
+
457
+ @field_validator("name")
458
+ def validate_name(cls, v):
459
+ Agent._validate_identifier(v, "Agent name")
460
+ Agent._sanitize_ecs_name(v, fallback=None)
461
+ return v
462
+
463
+ @field_validator("namespace")
464
+ def validate_namespace(cls, v):
465
+ Agent._validate_identifier(v, "Namespace")
466
+ return v
467
+
468
+ @classmethod
469
+ def create(
470
+ cls, name: str, functions: list[AgentFunction] | None = None, **kwargs
471
+ ) -> "Agent":
472
+ """Create a new Agent with ECS-sanitized name and timestamp."""
473
+ sanitized_name = cls._sanitize_ecs_name(name)
474
+ return cls(
475
+ name=sanitized_name,
476
+ functions=functions or [],
477
+ created_at=get_now_utc(),
478
+ last_updated=get_now_utc(),
479
+ **kwargs,
480
+ )
481
+
482
+ def get_network_url(self, base_url: str | None = None) -> str:
483
+ """Get the network URL for this agent."""
484
+ if base_url:
485
+ return f"{base_url.rstrip('/')}/{self.name}/dispatch"
486
+ return f"http://{self.name}.trigger/dispatch"
487
+
488
+ def handles_topic(self, topic: str) -> bool:
489
+ """Check if this agent handles the given topic (supports wildcards)."""
490
+ # Extract all topic triggers from functions
491
+ for function in self.functions:
492
+ for trigger in function.triggers:
493
+ if trigger.type == "topic" and trigger.topic:
494
+ agent_topic = trigger.topic
495
+ if agent_topic.endswith("*"):
496
+ if topic.startswith(agent_topic[:-1]):
497
+ return True
498
+ elif agent_topic == topic:
499
+ return True
500
+ return False
501
+
502
+ @staticmethod
503
+ def transform_topic_schemas_to_functions(
504
+ topic_schemas: dict[str, dict[str, Any]],
505
+ ) -> list[AgentFunction]:
506
+ """Transform legacy topic_schemas dict to functions list.
507
+
508
+ Used during agent registration to convert SDK-provided schemas
509
+ into the new functions format.
510
+
511
+ Args:
512
+ topic_schemas: Dict mapping topic names to schema metadata.
513
+ Each topic maps to a dict with keys: handler_name (str),
514
+ handler_doc (str or None), input_schema (dict),
515
+ output_schema (dict or None).
516
+
517
+ Returns:
518
+ List of AgentFunction objects, one per topic.
519
+ """
520
+ functions = []
521
+ for topic, schema_metadata in topic_schemas.items():
522
+ function = AgentFunction(
523
+ name=schema_metadata.get("handler_name", "handler"),
524
+ description=schema_metadata.get("handler_doc"),
525
+ input_schema=schema_metadata.get("input_schema", {}),
526
+ output_schema=schema_metadata.get("output_schema"),
527
+ triggers=[FunctionTrigger(type="topic", topic=topic)],
528
+ )
529
+ functions.append(function)
530
+ return functions
531
+
532
+ @staticmethod
533
+ def _validate_identifier(
534
+ identifier: str, identifier_type: str = "identifier"
535
+ ) -> None:
536
+ """Validate that an identifier doesn't contain colon character.
537
+
538
+ Colons are reserved as delimiters in task queue names to enable reliable parsing.
539
+
540
+ Args:
541
+ identifier: The string to validate (namespace, agent_name, etc.)
542
+ identifier_type: Description of what's being validated (for error messages)
543
+
544
+ Raises:
545
+ ValueError: If identifier contains a colon
546
+ """
547
+ if ":" in identifier:
548
+ raise ValueError(
549
+ f"{identifier_type} cannot contain colon ':' character "
550
+ f"(reserved as task queue delimiter): {identifier}"
551
+ )
552
+
553
+ @staticmethod
554
+ def _sanitize_ecs_name(name: str, fallback: str | None = None) -> str:
555
+ """Return a string valid for ECS names (family/service/container).
556
+ If fallback is None, raise exception if name is invalid.
557
+ Allowed characters are letters, numbers, hyphens, and underscores. Length must be 1-255.
558
+ Any other character is replaced with '-'. If the result is empty, use the fallback.
559
+ """
560
+ sanitized = "".join(ch if (ch.isalnum() or ch in "-_") else "-" for ch in name)
561
+ sanitized = sanitized.strip("-_")
562
+ if sanitized:
563
+ return sanitized[:255]
564
+ elif fallback is not None:
565
+ return Agent._sanitize_ecs_name(
566
+ fallback, None
567
+ ) # recurse to sanitize fallback
568
+ else:
569
+ raise ValueError(
570
+ f"Name '{name}' cannot be sanitized, and no fallback provided (or fallback also could not be sanitized)."
571
+ )
572
+
573
+ @staticmethod
574
+ def build_uid(org_id: str, namespace: str, agent_name: str) -> str:
575
+ """Build composite agent UID from components.
576
+
577
+ Args:
578
+ org_id: Organization identifier
579
+ namespace: Namespace identifier
580
+ agent_name: Agent name (ECS-sanitized)
581
+
582
+ Returns:
583
+ Composite UID in format: org_id#namespace#agent_name
584
+
585
+ Raises:
586
+ ValueError: If any component contains '#' separator
587
+
588
+ Example:
589
+ >>> Agent.build_uid("org123", "default", "my-agent")
590
+ "org123#default#my-agent"
591
+ """
592
+ if "#" in org_id or "#" in namespace or "#" in agent_name:
593
+ raise ValueError(
594
+ "Components cannot contain '#' separator. "
595
+ f"org_id={org_id}, namespace={namespace}, agent_name={agent_name}"
596
+ )
597
+ return f"{org_id}#{namespace}#{agent_name}"
598
+
599
+ @property
600
+ def uid(self) -> str:
601
+ """Computed unique identifier across all orgs and namespaces.
602
+
603
+ This property is computed from org_id, namespace, and name.
604
+ It is NOT stored in DynamoDB - only used as the primary key.
605
+
606
+ Format: org_id#namespace#name
607
+
608
+ Example:
609
+ >>> agent = Agent(name="my-agent", org_id="org123", namespace="default", ...)
610
+ >>> agent.uid
611
+ "org123#default#my-agent"
612
+ """
613
+ return Agent.build_uid(self.org_id, self.namespace, self.name)
614
+
615
+
616
+ # Router API Models for compatibility across implementations
617
+ class PublishEventBody(StrictBaseModel):
618
+ """Request body for publishing events to any dispatch router."""
619
+
620
+ topic: str
621
+ sender_id: str = "web-ui"
622
+ payload: Any = Field(default_factory=dict)
623
+ trace_id: str | None = None
624
+ parent_id: str | None = None
625
+
626
+
627
+ class SubscriptionBody(StrictBaseModel):
628
+ """Request body for agent subscription management."""
629
+
630
+ topics: list[str]
631
+ agent_name: str
632
+ functions: list[AgentFunction] | None = None
633
+
634
+
635
+ class EventRequest(StrictBaseModel):
636
+ """CLI router specific event request format."""
637
+
638
+ payload: Any
639
+ sender_id: str | None = "router"
640
+
641
+
642
+ class PublishResponse(StrictBaseModel):
643
+ """Standard response for event publishing operations.
644
+
645
+ When publishing to a topic, returns invocation IDs for all handlers triggered.
646
+ Clients can poll these invocations to get results (similar to invoke() pattern).
647
+ """
648
+
649
+ message: str
650
+ event_uid: str
651
+ invocation_ids: list[str] = [] # Invocation IDs for handlers triggered
652
+ handler_count: int = 0 # Number of handlers triggered
653
+
654
+
655
+ from dispatch_agents.invocation import InvocationStatus
656
+
657
+
658
+ class InvokeFunctionRequest(StrictBaseModel):
659
+ """Request to invoke a function on an agent.
660
+
661
+ This is the payload for POST /api/unstable/namespace/{namespace}/invoke
662
+ Used by the SDK's invoke() function and must match backend/local router expectations.
663
+ """
664
+
665
+ agent_name: str = Field(description="Target agent name")
666
+ function_name: str = Field(description="Function name to invoke")
667
+ payload: dict[str, Any] = Field(
668
+ default_factory=dict, description="Input payload for the function"
669
+ )
670
+ trace_id: str | None = Field(
671
+ default=None, description="Optional trace ID for distributed tracing"
672
+ )
673
+ parent_id: str | None = Field(
674
+ default=None, description="Optional parent span ID for distributed tracing"
675
+ )
676
+ timeout_seconds: int | None = Field(
677
+ default=None,
678
+ description="Optional timeout in seconds for the invocation. Defaults to 1 hour (3600s). Maximum is 24 hours (86400s).",
679
+ ge=1,
680
+ le=86400,
681
+ )
682
+
683
+
684
+ class InvocationStatusResponse(StrictBaseModel):
685
+ """Response from polling an invocation status.
686
+
687
+ Returned by GET /api/unstable/namespace/{namespace}/invoke/{invocation_id}
688
+ Contains the current status, agent/function info, and result (when completed).
689
+ """
690
+
691
+ invocation_id: str = Field(description="Unique invocation identifier")
692
+ status: InvocationStatus = Field(description="Current invocation status")
693
+ agent_name: str = Field(description="Target agent name")
694
+ function_name: str = Field(description="Function name")
695
+ trace_id: str = Field(description="Trace ID for distributed tracing")
696
+ result: Any | None = Field(
697
+ default=None, description="Result payload (when status is COMPLETED)"
698
+ )
699
+ error: str | None = Field(
700
+ default=None, description="Error message (when status is ERROR)"
701
+ )
702
+ created_at: str = Field(
703
+ description="ISO 8601 timestamp when invocation was created"
704
+ )
705
+
706
+
707
+ class SubscriptionResponse(StrictBaseModel):
708
+ """Standard response for subscription management operations."""
709
+
710
+ message: str
711
+ topics: list[str]
712
+ agent_name: str
713
+ subscribers: dict[str, int]
714
+
715
+
716
+ ########################################################
717
+ # Memory Models
718
+ ########################################################
719
+
720
+
721
+ class KVStoreRequest(StrictBaseModel):
722
+ agent_name: str
723
+ key: str
724
+ value: str = Field(default="")
725
+
726
+
727
+ class SessionStoreRequest(StrictBaseModel):
728
+ agent_name: str
729
+ session_id: str
730
+ session_data: dict[str, Any] = Field(default_factory=dict)
731
+
732
+
733
+ class MemoryWriteResponse(StrictBaseModel):
734
+ """Response from a memory write (add/delete) operation."""
735
+
736
+ message: str
737
+
738
+
739
+ class KVGetResponse(StrictBaseModel):
740
+ """Response from a long-term memory get operation."""
741
+
742
+ value: str | None
743
+
744
+
745
+ class SessionGetResponse(StrictBaseModel):
746
+ """Response from a short-term memory get operation."""
747
+
748
+ session_data: dict[str, Any]