gravity-cli 0.6.0__tar.gz → 0.7.0__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 (134) hide show
  1. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/PKG-INFO +2 -2
  2. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/__init__.py +18 -0
  3. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/contract.py +16 -0
  4. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/errors.py +3 -0
  5. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/manifest.py +403 -10
  6. gravity_cli-0.7.0/packages/gravity-schema/gravity_schema/rules.py +143 -0
  7. gravity_cli-0.7.0/packages/gravity-schema/gravity_schema/rules_cases.json +67 -0
  8. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/pyproject.toml +1 -1
  9. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/schema/manifest.schema.json +331 -1
  10. gravity_cli-0.7.0/packages/gravity-schema/tests/test_actions_memory.py +78 -0
  11. gravity_cli-0.7.0/packages/gravity-schema/tests/test_rules.py +88 -0
  12. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/pyproject.toml +2 -2
  13. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/BUILDER_GUIDE.md +51 -0
  14. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/CHANGELOG.md +8 -0
  15. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/cli.py +2 -0
  16. gravity_cli-0.7.0/src/gravity_cli/commands/memory.py +63 -0
  17. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/run.py +52 -16
  18. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/schedule.py +3 -8
  19. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/dev.py +7 -3
  20. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/dev_page/index.html +63 -13
  21. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/docs/Gravity.agent.md +211 -58
  22. gravity_cli-0.7.0/src/gravity_cli/inputs.py +56 -0
  23. gravity_cli-0.7.0/tests/test_inputs_memory.py +72 -0
  24. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/uv.lock +2 -2
  25. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/.env.example +0 -0
  26. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/.github/workflows/ci.yml +0 -0
  27. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/.github/workflows/release.yml +0 -0
  28. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/.gitignore +0 -0
  29. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/AGENT_STANDARD.md +0 -0
  30. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/LICENSE +0 -0
  31. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/NOTICE +0 -0
  32. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/README.md +0 -0
  33. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/RELEASING.md +0 -0
  34. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/TEMPLATES.md +0 -0
  35. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/LICENSE +0 -0
  36. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/NOTICE +0 -0
  37. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/README.md +0 -0
  38. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/__main__.py +0 -0
  39. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/catalog.py +0 -0
  40. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/catalog.yaml +0 -0
  41. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/catalog_imported.yaml +0 -0
  42. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/cron.py +0 -0
  43. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/export.py +0 -0
  44. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/gen_catalog.py +0 -0
  45. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/source_scan.py +0 -0
  46. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/gravity_schema/validation.py +0 -0
  47. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/conftest.py +0 -0
  48. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_catalog_parity.py +0 -0
  49. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_cron.py +0 -0
  50. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_errors.py +0 -0
  51. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_json_schema.py +0 -0
  52. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_kind_outputs.py +0 -0
  53. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_manifest.py +0 -0
  54. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_slots.py +0 -0
  55. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_source_scan.py +0 -0
  56. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/packages/gravity-schema/tests/test_validation.py +0 -0
  57. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/__init__.py +0 -0
  58. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/__main__.py +0 -0
  59. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/_runner_script.py +0 -0
  60. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/api.py +0 -0
  61. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/changelog.py +0 -0
  62. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/__init__.py +0 -0
  63. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/adapt.py +0 -0
  64. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/build.py +0 -0
  65. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/changelog.py +0 -0
  66. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/connect.py +0 -0
  67. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/dev.py +0 -0
  68. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/docs.py +0 -0
  69. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/guide.py +0 -0
  70. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/init.py +0 -0
  71. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/login.py +0 -0
  72. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/push.py +0 -0
  73. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/runs.py +0 -0
  74. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/tools.py +0 -0
  75. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/update.py +0 -0
  76. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/commands/validate.py +0 -0
  77. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/config.py +0 -0
  78. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/docs/problem-to-agent.md +0 -0
  79. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/localrun.py +0 -0
  80. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/manifest.py +0 -0
  81. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/project.py +0 -0
  82. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/chat/.gitignore +0 -0
  83. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/chat/manifest.yaml +0 -0
  84. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/chat/requirements.txt +0 -0
  85. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/chat/src/agent.py +0 -0
  86. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/deepagent/.gitignore +0 -0
  87. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/deepagent/manifest.yaml +0 -0
  88. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/deepagent/requirements.txt +0 -0
  89. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/deepagent/src/agent.py +0 -0
  90. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/minimal/.gitignore +0 -0
  91. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/minimal/manifest.yaml +0 -0
  92. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/minimal/requirements.txt +0 -0
  93. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/minimal/src/agent.py +0 -0
  94. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/.gitignore +0 -0
  95. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/manifest.yaml +0 -0
  96. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/requirements.txt +0 -0
  97. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/src/agent.py +0 -0
  98. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/src/gateway.py +0 -0
  99. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/src/nodes/agent.py +0 -0
  100. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/src/prompts.py +0 -0
  101. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/templates/structured/src/state.py +0 -0
  102. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/timeline.py +0 -0
  103. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/ui.py +0 -0
  104. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/updater.py +0 -0
  105. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/src/gravity_cli/workspace.py +0 -0
  106. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/starter/manifest.yaml +0 -0
  107. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/starter/requirements.txt +0 -0
  108. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/starter/src/agent.py +0 -0
  109. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/starter/src/summary.py +0 -0
  110. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/starter/tests/test_summary.py +0 -0
  111. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/conftest.py +0 -0
  112. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_adapt.py +0 -0
  113. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_agent_guide.py +0 -0
  114. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_agents.py +0 -0
  115. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_api.py +0 -0
  116. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_build_push.py +0 -0
  117. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_changelog.py +0 -0
  118. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_connect.py +0 -0
  119. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_dev.py +0 -0
  120. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_gateway_token.py +0 -0
  121. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_localrun.py +0 -0
  122. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_manifest_integration.py +0 -0
  123. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_run.py +0 -0
  124. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_runs.py +0 -0
  125. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_schedule.py +0 -0
  126. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_session.py +0 -0
  127. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_templates.py +0 -0
  128. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_timeline.py +0 -0
  129. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_tools.py +0 -0
  130. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_ui.py +0 -0
  131. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_updater.py +0 -0
  132. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_validate.py +0 -0
  133. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_welcome.py +0 -0
  134. {gravity_cli-0.6.0 → gravity_cli-0.7.0}/tests/test_workspace.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gravity-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: gravity cli — init, run, and push python agents to the Gravity marketplace
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
7
7
  License-File: NOTICE
8
8
  Requires-Python: >=3.12
9
- Requires-Dist: gravity-schema==0.6.0
9
+ Requires-Dist: gravity-schema==0.7.0
10
10
  Requires-Dist: httpx>=0.27.0
11
11
  Requires-Dist: keyring>=25.0.0
12
12
  Requires-Dist: python-dotenv>=1.0.0
@@ -22,13 +22,19 @@ from .errors import (
22
22
  )
23
23
  from .export import manifest_json_schema
24
24
  from .manifest import (
25
+ ActionSpec,
25
26
  ApprovalsSpec,
27
+ ChooseOneSpec,
28
+ CollectionSpec,
26
29
  DependenciesSpec,
27
30
  InputSpec,
28
31
  InputType,
29
32
  Manifest,
33
+ MemoryScope,
34
+ MemorySpec,
30
35
  ModelsSpec,
31
36
  ModelTier,
37
+ OfferAs,
32
38
  PermissionsSpec,
33
39
  ResourcesSpec,
34
40
  RuntimeSpec,
@@ -36,6 +42,7 @@ from .manifest import (
36
42
  StateSpec,
37
43
  TriggersSpec,
38
44
  )
45
+ from .rules import apply_rules, chosen_actions, problems, visible_inputs
39
46
  from .source_scan import is_agent_source, undeclared_write_tools
40
47
  from .validation import (
41
48
  check_imported_write_approvals,
@@ -70,6 +77,17 @@ __all__ = [ # noqa: RUF022 - grouped by concern, not alphabetical
70
77
  "ScheduleSpec",
71
78
  "ApprovalsSpec",
72
79
  "StateSpec",
80
+ "MemorySpec",
81
+ "MemoryScope",
82
+ "CollectionSpec",
83
+ "ChooseOneSpec",
84
+ "ActionSpec",
85
+ "OfferAs",
86
+ # input rules
87
+ "visible_inputs",
88
+ "apply_rules",
89
+ "problems",
90
+ "chosen_actions",
73
91
  # validation
74
92
  "validate_manifest",
75
93
  "load_manifest_text",
@@ -67,6 +67,22 @@ MAX_MEMORY_MB: Final[int] = 4096
67
67
  MIN_STATE_KB: Final[int] = 1
68
68
  MAX_STATE_KB: Final[int] = 256
69
69
 
70
+ # Memory collections (manifest `memory.collections`): keyed records, per user or per thread.
71
+ MAX_COLLECTIONS: Final[int] = 20
72
+ MAX_ITEM_KB: Final[int] = 64
73
+ DEFAULT_ITEM_KB: Final[int] = 16
74
+ MAX_ITEMS_PER_COLLECTION: Final[int] = 10_000
75
+ MAX_KEEP_DAYS: Final[int] = 730
76
+ DEFAULT_KEEP_DAYS: Final[int] = 30
77
+ # Across every collection and the notebook, one user's memory of one agent.
78
+ MAX_MEMORY_BYTES_PER_USER: Final[int] = 25 * 1024 * 1024
79
+ MAX_MEMORY_WRITES_PER_RUN: Final[int] = 5_000
80
+ # memory.previous(): the last finished run's result, passed whole up to this size.
81
+ MAX_PREVIOUS_KB: Final[int] = 256
82
+
83
+ # Declared actions (manifest `actions`).
84
+ MAX_ACTIONS: Final[int] = 20
85
+
70
86
 
71
87
  # Generous bounds, so a pathological manifest can't tie up the validator.
72
88
 
@@ -36,6 +36,9 @@ class ErrorCode(str, Enum):
36
36
  INVALID_STATE = "INVALID_STATE"
37
37
  INVALID_KIND = "INVALID_KIND"
38
38
  INVALID_OUTPUT = "INVALID_OUTPUT"
39
+ INVALID_CONDITION = "INVALID_CONDITION"
40
+ INVALID_ACTION = "INVALID_ACTION"
41
+ INVALID_MEMORY = "INVALID_MEMORY"
39
42
  LOCKFILE_MISMATCH = "LOCKFILE_MISMATCH"
40
43
  MISSING_FIELD = "MISSING_FIELD"
41
44
  UNKNOWN_FIELD = "UNKNOWN_FIELD"
@@ -34,6 +34,10 @@ class InputType(str, Enum):
34
34
  SECRET = "secret"
35
35
  # The agent gets a short-lived URL it fetches through the gateway.
36
36
  FILE = "file"
37
+ # Several of `options`; the value is a list.
38
+ MULTI_SELECT = "multi-select"
39
+ # One of this agent's declared `actions`; the chosen action runs (rules.py).
40
+ ACTION_CHOICE = "action-choice"
37
41
 
38
42
 
39
43
  # Which Python types each input's `default` may be.
@@ -48,8 +52,14 @@ _PYTHON_TYPES: dict[InputType, tuple[type, ...]] = {
48
52
  InputType.DATE: (str,),
49
53
  InputType.SECRET: (str,),
50
54
  InputType.FILE: (), # never has a default
55
+ InputType.MULTI_SELECT: (list,),
56
+ InputType.ACTION_CHOICE: (str,),
51
57
  }
52
58
 
59
+ # A `show_if` value: what the named input or slot must be for this question to be asked.
60
+ Scalar = str | bool | int | float
61
+ ShowIf = dict[str, Scalar | list[Scalar]]
62
+
53
63
 
54
64
  class ModelTier(str, Enum):
55
65
  """Capability tier, not a model name - the LLM gateway resolves it."""
@@ -189,6 +199,13 @@ class InputSpec(BaseModel):
189
199
  # File inputs only: extensions without the dot. None accepts any file.
190
200
  accept: Annotated[list[str] | None, Field(max_length=C.MAX_FILE_EXTENSIONS)] = None
191
201
  default: Any | None = None
202
+ # Asked only when every named earlier input or slot has one of the given values (rules.py).
203
+ show_if: Annotated[ShowIf | None, Field(max_length=C.MAX_INPUTS)] = None
204
+ # multi-select only: how many options may be picked.
205
+ min: Annotated[int | None, Field(ge=0, le=C.MAX_OPTIONS)] = None
206
+ max: Annotated[int | None, Field(ge=1, le=C.MAX_OPTIONS)] = None
207
+ # action-choice only: the declared actions offered, in order.
208
+ actions: Annotated[list[str] | None, Field(max_length=C.MAX_ACTIONS)] = None
192
209
 
193
210
  @field_validator("name")
194
211
  @classmethod
@@ -210,28 +227,69 @@ class InputSpec(BaseModel):
210
227
 
211
228
  @model_validator(mode="after")
212
229
  def _check_options(self) -> InputSpec:
213
- is_dropdown = self.type is InputType.STATIC_DROPDOWN
230
+ is_dropdown = self.type in (InputType.STATIC_DROPDOWN, InputType.MULTI_SELECT)
214
231
 
215
232
  if is_dropdown and not self.options:
216
233
  raise PydanticCustomError(
217
234
  ErrorCode.INVALID_INPUT.value,
218
- "Input '{value}' is a static-dropdown but declares no options.",
235
+ "Input '{value}' is a {type} but declares no options.",
219
236
  {
220
237
  "value": self.name,
238
+ "type": self.type.value,
221
239
  "remediation": "Add an `options:` list, or use type 'text'.",
222
240
  },
223
241
  )
224
242
  if self.options and not is_dropdown:
225
243
  raise PydanticCustomError(
226
244
  ErrorCode.INVALID_INPUT.value,
227
- "Input '{value}' declares options but is not a static-dropdown.",
245
+ "Input '{value}' declares options but is not a static-dropdown or multi-select.",
228
246
  {
229
247
  "value": self.name,
230
- "remediation": "Set type to 'static-dropdown', or remove `options`.",
248
+ "remediation": "Set type to 'static-dropdown' or 'multi-select', or remove `options`.",
231
249
  },
232
250
  )
233
251
  return self
234
252
 
253
+ @model_validator(mode="after")
254
+ def _check_choice_fields(self) -> InputSpec:
255
+ multi = self.type is InputType.MULTI_SELECT
256
+ if (self.min is not None or self.max is not None) and not multi:
257
+ raise PydanticCustomError(
258
+ ErrorCode.INVALID_INPUT.value,
259
+ "Input '{value}' sets min or max but is not a multi-select.",
260
+ {"value": self.name, "remediation": "Set type to 'multi-select', or remove min and max."},
261
+ )
262
+ if multi:
263
+ count = len(self.options or [])
264
+ lo, hi = self.min or 0, self.max if self.max is not None else count
265
+ if lo > hi or hi > count:
266
+ raise PydanticCustomError(
267
+ ErrorCode.INVALID_INPUT.value,
268
+ "Input '{value}' asks for between {lo} and {hi} picks of {count} options.",
269
+ {"value": self.name, "lo": lo, "hi": hi, "count": count,
270
+ "remediation": "Keep min <= max <= the number of options."},
271
+ )
272
+ choice = self.type is InputType.ACTION_CHOICE
273
+ if choice and not self.actions:
274
+ raise PydanticCustomError(
275
+ ErrorCode.INVALID_INPUT.value,
276
+ "Input '{value}' is an action-choice but lists no actions.",
277
+ {"value": self.name, "remediation": "Add `actions:` with the names of declared actions."},
278
+ )
279
+ if self.actions is not None and not choice:
280
+ raise PydanticCustomError(
281
+ ErrorCode.INVALID_INPUT.value,
282
+ "Input '{value}' lists actions but is not an action-choice.",
283
+ {"value": self.name, "remediation": "Set type to 'action-choice', or remove `actions`."},
284
+ )
285
+ if self.actions and len(set(self.actions)) != len(self.actions):
286
+ raise PydanticCustomError(
287
+ ErrorCode.INVALID_INPUT.value,
288
+ "Input '{value}' lists an action twice.",
289
+ {"value": self.name, "remediation": "List each action once."},
290
+ )
291
+ return self
292
+
235
293
  @model_validator(mode="after")
236
294
  def _check_accept(self) -> InputSpec:
237
295
  if self.accept is None:
@@ -304,13 +362,15 @@ class InputSpec(BaseModel):
304
362
  },
305
363
  )
306
364
 
307
- if self.options and self.default not in self.options:
365
+ picks = self.default if isinstance(self.default, list) else [self.default]
366
+ allowed = self.options if self.options else self.actions
367
+ if allowed and any(p not in allowed for p in picks):
308
368
  raise PydanticCustomError(
309
369
  ErrorCode.INVALID_INPUT.value,
310
370
  "Default for input '{value}' is not one of its options.",
311
371
  {
312
372
  "value": self.name,
313
- "remediation": f"Use one of: {', '.join(self.options)}.",
373
+ "remediation": f"Use one of: {', '.join(allowed)}.",
314
374
  },
315
375
  )
316
376
  return self
@@ -462,6 +522,51 @@ class StateSpec(BaseModel):
462
522
  max_kb: Annotated[int, Field(ge=C.MIN_STATE_KB, le=C.MAX_STATE_KB)] = C.MAX_STATE_KB
463
523
 
464
524
 
525
+ class MemoryScope(str, Enum):
526
+ """Whose memory: shared by all of one user's conversations with the agent, or one conversation's."""
527
+
528
+ USER = "user"
529
+ THREAD = "thread"
530
+
531
+
532
+ class CollectionSpec(BaseModel):
533
+ """Keyed records the agent keeps across runs. Every item expires `keep_days` after its last write."""
534
+
535
+ model_config = _STRICT
536
+
537
+ per: MemoryScope = MemoryScope.USER
538
+ keep_days: Annotated[int, Field(ge=1, le=C.MAX_KEEP_DAYS)] = C.DEFAULT_KEEP_DAYS
539
+ max_items: Annotated[int, Field(ge=1, le=C.MAX_ITEMS_PER_COLLECTION)] = C.MAX_ITEMS_PER_COLLECTION
540
+ max_item_kb: Annotated[int, Field(ge=1, le=C.MAX_ITEM_KB)] = C.DEFAULT_ITEM_KB
541
+
542
+
543
+ class MemorySpec(BaseModel):
544
+ """Memory across runs: the small notebook (what `state.max_kb` was) and named collections."""
545
+
546
+ model_config = _STRICT
547
+
548
+ notebook_kb: Annotated[int, Field(ge=C.MIN_STATE_KB, le=C.MAX_STATE_KB)] | None = None
549
+ notebook_per: MemoryScope = MemoryScope.USER
550
+ collections: dict[str, CollectionSpec] = Field(default_factory=dict)
551
+
552
+ @model_validator(mode="after")
553
+ def _check_collections(self) -> MemorySpec:
554
+ if len(self.collections) > C.MAX_COLLECTIONS:
555
+ raise PydanticCustomError(
556
+ ErrorCode.LIMIT_EXCEEDED.value,
557
+ "At most {limit} memory collections.",
558
+ {"limit": C.MAX_COLLECTIONS, "remediation": "Merge collections, or keep several kinds of record in one."},
559
+ )
560
+ for name in self.collections:
561
+ if not C.INPUT_NAME_RE.match(name):
562
+ raise PydanticCustomError(
563
+ ErrorCode.INVALID_MEMORY.value,
564
+ "Collection name '{value}' is not a valid identifier.",
565
+ {"value": name, "remediation": "Use lowercase letters, digits and underscores."},
566
+ )
567
+ return self
568
+
569
+
465
570
  class OutputSpec(BaseModel):
466
571
  """One rendering of the run's corpus a consumer may pick; the platform delivers it, not the agent."""
467
572
 
@@ -499,6 +604,8 @@ class SlotSpec(BaseModel):
499
604
  label: Annotated[str, Field(max_length=C.MAX_LABEL_LENGTH)]
500
605
  default: str
501
606
  adapters: dict[str, list[str]] # adapter name -> the catalog tools it calls
607
+ # The consumer may pick several adapters; the pick is a list and every pick's tools are granted.
608
+ multiple: bool = False
502
609
 
503
610
  @model_validator(mode="after")
504
611
  def _check(self) -> SlotSpec:
@@ -550,6 +657,82 @@ class DisplaySpec(BaseModel):
550
657
  chart: ChartHint | None = None
551
658
 
552
659
 
660
+ class ChooseOneSpec(BaseModel):
661
+ """An "either / or" group: once one of these inputs is filled the others are hidden.
662
+ With `required`, one of them must be filled."""
663
+
664
+ model_config = _STRICT
665
+
666
+ label: Annotated[str, Field(max_length=C.MAX_LABEL_LENGTH)]
667
+ inputs: Annotated[list[str], Field(min_length=2, max_length=C.MAX_INPUTS)]
668
+ required: bool = False
669
+
670
+
671
+ class ActionSpec(BaseModel):
672
+ """One thing the agent can do. The runner passes the chosen ones in state["actions"]."""
673
+
674
+ model_config = _STRICT
675
+
676
+ name: Annotated[str, Field(max_length=C.MAX_NAME_LENGTH)]
677
+ label: Annotated[str, Field(max_length=C.MAX_LABEL_LENGTH)]
678
+ # Runs on the first run of a conversation, with the other defaults, in declaration order.
679
+ default: bool = False
680
+ # Offered only once these actions have completed in the conversation.
681
+ requires: Annotated[list[str], Field(max_length=C.MAX_ACTIONS)] = Field(default_factory=list)
682
+ # Works on the rows the person selects in the result; their keys arrive as action_inputs["rows"].
683
+ per_row: bool = False
684
+ # Extra questions asked only for this action, under the same rules as top-level inputs.
685
+ inputs: Annotated[list[InputSpec], Field(max_length=C.MAX_INPUTS)] = Field(default_factory=list)
686
+ # Its own recurring run; the consumer turns it on.
687
+ schedule: ScheduleSpec | None = None
688
+ # Never scheduled and never repeated by a whole-flow schedule after it once succeeded.
689
+ run_once: bool = False
690
+ # Run these next, each as its own run on the same conversation, when this one completes.
691
+ then: Annotated[list[str], Field(max_length=C.MAX_ACTIONS)] = Field(default_factory=list)
692
+
693
+ @field_validator("name")
694
+ @classmethod
695
+ def _check_name(cls, v: str) -> str:
696
+ if not C.INPUT_NAME_RE.match(v):
697
+ raise PydanticCustomError(
698
+ ErrorCode.INVALID_ACTION.value,
699
+ "Action name '{value}' is not a valid identifier.",
700
+ {"value": v, "remediation": "Use lowercase letters, digits and underscores."},
701
+ )
702
+ return v
703
+
704
+ @model_validator(mode="after")
705
+ def _check(self) -> ActionSpec:
706
+ if self.run_once and self.schedule is not None:
707
+ raise PydanticCustomError(
708
+ ErrorCode.INVALID_ACTION.value,
709
+ "Action '{value}' is run_once, so it cannot have a schedule.",
710
+ {"value": self.name, "remediation": "Remove `schedule` or `run_once`."},
711
+ )
712
+ names = [i.name for i in self.inputs]
713
+ if len(set(names)) != len(names):
714
+ raise PydanticCustomError(
715
+ ErrorCode.DUPLICATE_INPUT.value,
716
+ "Action '{value}' declares an input twice.",
717
+ {"value": self.name, "remediation": "Rename or remove the duplicate input."},
718
+ )
719
+ for spec in self.inputs:
720
+ if spec.type is InputType.ACTION_CHOICE:
721
+ raise PydanticCustomError(
722
+ ErrorCode.INVALID_ACTION.value,
723
+ "Action '{value}' asks an action-choice question.",
724
+ {"value": self.name, "remediation": "Offer actions at the top level or under the output."},
725
+ )
726
+ return self
727
+
728
+
729
+ class OfferAs(str, Enum):
730
+ """How the actions under a result are shown."""
731
+
732
+ BUTTONS = "buttons"
733
+ QUESTION = "question"
734
+
735
+
553
736
  class Manifest(BaseModel):
554
737
  """A complete agent declaration. Structural only: tool existence is checked in validation.py."""
555
738
 
@@ -576,6 +759,14 @@ class Manifest(BaseModel):
576
759
  outputs: dict[str, OutputSpec] = Field(default_factory=dict)
577
760
  # How the result shows on screen; absent, the page decides from the result itself.
578
761
  display: DisplaySpec | None = None
762
+ # "Either / or" input groups (rules.py).
763
+ choose_one: Annotated[list[ChooseOneSpec], Field(max_length=C.MAX_INPUTS)] = Field(default_factory=list)
764
+ # Memory across runs; `state` is the older spelling of the notebook alone.
765
+ memory: MemorySpec | None = None
766
+ # What the agent can do. Absent: one entry point, as always.
767
+ actions: Annotated[list[ActionSpec], Field(max_length=C.MAX_ACTIONS)] = Field(default_factory=list)
768
+ offer_as: OfferAs = OfferAs.BUTTONS
769
+ offer_label: Annotated[str, Field(max_length=C.MAX_LABEL_LENGTH)] | None = None
579
770
 
580
771
  @field_validator("contract")
581
772
  @classmethod
@@ -706,6 +897,133 @@ class Manifest(BaseModel):
706
897
  )
707
898
  return self
708
899
 
900
+ @model_validator(mode="after")
901
+ def _check_memory(self) -> Manifest:
902
+ if self.state is not None and self.memory is not None:
903
+ raise PydanticCustomError(
904
+ ErrorCode.INVALID_MEMORY.value,
905
+ "Both `state` and `memory` are declared.",
906
+ {"remediation": "Move state.max_kb to memory.notebook_kb and remove `state`."},
907
+ )
908
+ return self
909
+
910
+ @model_validator(mode="after")
911
+ def _check_actions(self) -> Manifest:
912
+ by_name: dict[str, ActionSpec] = {}
913
+ for a in self.actions:
914
+ if a.name in by_name:
915
+ raise PydanticCustomError(
916
+ ErrorCode.INVALID_ACTION.value,
917
+ "Action '{value}' is declared more than once.",
918
+ {"value": a.name, "remediation": "Rename or remove the duplicate action."},
919
+ )
920
+ by_name[a.name] = a
921
+ for a in self.actions:
922
+ for field in ("requires", "then"):
923
+ unknown = [n for n in getattr(a, field) if n not in by_name or n == a.name]
924
+ if unknown:
925
+ raise PydanticCustomError(
926
+ ErrorCode.INVALID_ACTION.value,
927
+ "Action '{value}' {field} '{other}', which is not another declared action.",
928
+ {"value": a.name, "field": field, "other": unknown[0],
929
+ "remediation": f"Use one of: {', '.join(n for n in by_name if n != a.name) or '(none)'}."},
930
+ )
931
+ for n in a.then:
932
+ if by_name[n].run_once:
933
+ raise PydanticCustomError(
934
+ ErrorCode.INVALID_ACTION.value,
935
+ "Action '{value}' chains into '{other}', which is run_once.",
936
+ {"value": a.name, "other": n, "remediation": "Chain only actions that may run again."},
937
+ )
938
+ for field in ("requires", "then"):
939
+ loop = _cycle({a.name: getattr(a, field) for a in self.actions})
940
+ if loop:
941
+ raise PydanticCustomError(
942
+ ErrorCode.INVALID_ACTION.value,
943
+ "Actions loop through `{field}`: {value}.",
944
+ {"field": field, "value": " -> ".join(loop), "remediation": "Break the loop."},
945
+ )
946
+ # Default actions run first, in order, so each one's requirements must be earlier defaults.
947
+ done: set[str] = set()
948
+ for a in self.actions:
949
+ if not a.default:
950
+ continue
951
+ missing = [n for n in a.requires if n not in done]
952
+ if missing:
953
+ raise PydanticCustomError(
954
+ ErrorCode.INVALID_ACTION.value,
955
+ "Default action '{value}' requires '{other}', which does not run before it.",
956
+ {"value": a.name, "other": missing[0],
957
+ "remediation": "Make that action a default declared earlier, or drop `default`."},
958
+ )
959
+ done.add(a.name)
960
+ if self.actions and not done:
961
+ raise PydanticCustomError(
962
+ ErrorCode.INVALID_ACTION.value,
963
+ "No action is marked `default: true`, so a first run would do nothing.",
964
+ {"remediation": "Mark the first action(s) default: true."},
965
+ )
966
+ for i in [*self.inputs, *(x for a in self.actions for x in a.inputs)]:
967
+ unknown = [n for n in i.actions or [] if n not in by_name]
968
+ if unknown:
969
+ raise PydanticCustomError(
970
+ ErrorCode.INVALID_ACTION.value,
971
+ "Input '{value}' offers action '{other}', which is not declared.",
972
+ {"value": i.name, "other": unknown[0], "remediation": "Declare it under `actions`."},
973
+ )
974
+ return self
975
+
976
+ @model_validator(mode="after")
977
+ def _check_conditions(self) -> Manifest:
978
+ # Slots are asked first, so any slot counts as earlier; an input only after it is declared.
979
+ _check_show_if(self.inputs, self.slots, "inputs")
980
+ for a in self.actions:
981
+ _check_show_if(a.inputs, self.slots, f"actions.{a.name}.inputs", earlier=self.inputs)
982
+ names = {i.name: i for i in self.inputs}
983
+ grouped: set[str] = set()
984
+ for g in self.choose_one:
985
+ for n in g.inputs:
986
+ if n not in names:
987
+ raise PydanticCustomError(
988
+ ErrorCode.INVALID_CONDITION.value,
989
+ "choose_one '{value}' names '{other}', which is not an input.",
990
+ {"value": g.label, "other": n, "remediation": "Name declared inputs only."},
991
+ )
992
+ if n in grouped:
993
+ raise PydanticCustomError(
994
+ ErrorCode.INVALID_CONDITION.value,
995
+ "Input '{value}' is in more than one choose_one group.",
996
+ {"value": n, "remediation": "Keep each input in one group."},
997
+ )
998
+ if names[n].required:
999
+ raise PydanticCustomError(
1000
+ ErrorCode.INVALID_CONDITION.value,
1001
+ "Input '{value}' is in a choose_one group and also required on its own.",
1002
+ {"value": n, "remediation": "Remove its `required`; set `required: true` on the group."},
1003
+ )
1004
+ grouped.add(n)
1005
+ if len(set(g.inputs)) != len(g.inputs):
1006
+ raise PydanticCustomError(
1007
+ ErrorCode.INVALID_CONDITION.value,
1008
+ "choose_one '{value}' names an input twice.",
1009
+ {"value": g.label, "remediation": "List each input once."},
1010
+ )
1011
+ return self
1012
+
1013
+ @property
1014
+ def notebook_kb(self) -> int | None:
1015
+ """The notebook's cap, None when the agent keeps no notebook."""
1016
+ if self.memory is not None:
1017
+ return self.memory.notebook_kb
1018
+ return self.state.max_kb if self.state is not None else None
1019
+
1020
+ @property
1021
+ def default_actions(self) -> list[str]:
1022
+ return [a.name for a in self.actions if a.default]
1023
+
1024
+ def action(self, name: str) -> ActionSpec | None:
1025
+ return next((a for a in self.actions if a.name == name), None)
1026
+
709
1027
  @property
710
1028
  def declared_tools(self) -> frozenset[str]:
711
1029
  """Every tool this agent could ever be granted, across all adapters."""
@@ -718,9 +1036,11 @@ class Manifest(BaseModel):
718
1036
  tools = list(self.permissions.tools)
719
1037
  for name, slot in self.slots.items():
720
1038
  pick = inputs.get(name, slot.default)
721
- if pick not in slot.adapters:
722
- raise ValueError(f"{name}={pick!r}: not an adapter of this agent ({', '.join(slot.adapters)})")
723
- tools += [t for t in slot.adapters[pick] if t not in tools]
1039
+ picks = pick if slot.multiple and isinstance(pick, list) and pick else [pick]
1040
+ for p in picks:
1041
+ if not isinstance(p, str) or p not in slot.adapters:
1042
+ raise ValueError(f"{name}={p!r}: not an adapter of this agent ({', '.join(slot.adapters)})")
1043
+ tools += [t for t in slot.adapters[p] if t not in tools]
724
1044
  return tools
725
1045
 
726
1046
  def entrypoint_parts(self) -> tuple[str, str]:
@@ -728,5 +1048,78 @@ class Manifest(BaseModel):
728
1048
 
729
1049
  def default_inputs(self) -> dict[str, Any]:
730
1050
  return {i.name: i.default for i in self.inputs if i.default is not None} | {
731
- name: s.default for name, s in self.slots.items()
1051
+ name: [s.default] if s.multiple else s.default for name, s in self.slots.items()
732
1052
  }
1053
+
1054
+
1055
+ def _cycle(edges: dict[str, list[str]]) -> list[str] | None:
1056
+ """A loop in `edges` as [a, b, ..., a], or None."""
1057
+ state: dict[str, int] = {} # 1 visiting, 2 done
1058
+ path: list[str] = []
1059
+
1060
+ def visit(n: str) -> list[str] | None:
1061
+ state[n] = 1
1062
+ path.append(n)
1063
+ for m in edges.get(n, []):
1064
+ if state.get(m) == 1:
1065
+ return [*path[path.index(m):], m]
1066
+ if state.get(m) is None and (found := visit(m)):
1067
+ return found
1068
+ path.pop()
1069
+ state[n] = 2
1070
+ return None
1071
+
1072
+ for n in edges:
1073
+ if state.get(n) is None and (found := visit(n)):
1074
+ return found
1075
+ return None
1076
+
1077
+
1078
+ def _check_show_if(
1079
+ inputs: list[InputSpec],
1080
+ slots: dict[str, SlotSpec],
1081
+ where: str,
1082
+ earlier: list[InputSpec] = (),
1083
+ ) -> None:
1084
+ """Each condition names a slot or an input asked before this one, with values it can take."""
1085
+ seen: dict[str, InputSpec] = {i.name: i for i in earlier}
1086
+ for spec in inputs:
1087
+ for ref, want in (spec.show_if or {}).items():
1088
+ values = want if isinstance(want, list) else [want]
1089
+ if not values:
1090
+ raise PydanticCustomError(
1091
+ ErrorCode.INVALID_CONDITION.value,
1092
+ "{where}.{value}.show_if.{ref} lists no values.",
1093
+ {"where": where, "value": spec.name, "ref": ref, "remediation": "Give one value or a list."},
1094
+ )
1095
+ if ref in slots:
1096
+ allowed: list[Any] | None = list(slots[ref].adapters)
1097
+ elif ref in seen:
1098
+ other = seen[ref]
1099
+ if other.type is InputType.BOOLEAN:
1100
+ allowed = [True, False]
1101
+ elif other.options:
1102
+ allowed = list(other.options)
1103
+ elif other.actions:
1104
+ allowed = list(other.actions)
1105
+ else:
1106
+ allowed = None # free text: any value
1107
+ else:
1108
+ raise PydanticCustomError(
1109
+ ErrorCode.INVALID_CONDITION.value,
1110
+ "{where}.{value}.show_if names '{ref}', which is not a slot or an input asked before it.",
1111
+ {"where": where, "value": spec.name, "ref": ref,
1112
+ "remediation": "Name a slot, or an input declared above this one."},
1113
+ )
1114
+ bad = [v for v in values if allowed is not None and v not in allowed]
1115
+ # True == 1 in Python: a boolean condition must be written as a boolean.
1116
+ if seen.get(ref) is not None and seen[ref].type is InputType.BOOLEAN:
1117
+ bad += [v for v in values if not isinstance(v, bool)]
1118
+ if bad:
1119
+ raise PydanticCustomError(
1120
+ ErrorCode.INVALID_CONDITION.value,
1121
+ "{where}.{value}.show_if.{ref} = {bad!r} is not a value '{ref}' can take.",
1122
+ {"where": where, "value": spec.name, "ref": ref, "bad": bad[0],
1123
+ "remediation": f"Use one of: {', '.join(map(str, allowed or []))}."},
1124
+ )
1125
+ seen[spec.name] = spec