lionagi 0.17.11__py3-none-any.whl → 0.18.1__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 (109) hide show
  1. lionagi/_errors.py +0 -5
  2. lionagi/fields.py +83 -0
  3. lionagi/libs/schema/minimal_yaml.py +98 -0
  4. lionagi/ln/__init__.py +3 -1
  5. lionagi/ln/concurrency/primitives.py +4 -4
  6. lionagi/ln/concurrency/task.py +1 -0
  7. lionagi/ln/types.py +32 -5
  8. lionagi/models/field_model.py +21 -4
  9. lionagi/models/hashable_model.py +2 -3
  10. lionagi/operations/ReAct/ReAct.py +475 -238
  11. lionagi/operations/ReAct/utils.py +3 -0
  12. lionagi/operations/act/act.py +206 -0
  13. lionagi/operations/builder.py +5 -7
  14. lionagi/operations/chat/chat.py +130 -114
  15. lionagi/operations/communicate/communicate.py +101 -42
  16. lionagi/operations/fields.py +380 -0
  17. lionagi/operations/flow.py +8 -10
  18. lionagi/operations/interpret/interpret.py +65 -20
  19. lionagi/operations/node.py +4 -4
  20. lionagi/operations/operate/operate.py +216 -108
  21. lionagi/{protocols/operatives → operations/operate}/operative.py +4 -5
  22. lionagi/{protocols/operatives → operations/operate}/step.py +34 -39
  23. lionagi/operations/parse/parse.py +170 -142
  24. lionagi/operations/select/select.py +79 -18
  25. lionagi/operations/select/utils.py +8 -2
  26. lionagi/operations/types.py +119 -23
  27. lionagi/protocols/action/manager.py +5 -6
  28. lionagi/protocols/contracts.py +2 -2
  29. lionagi/protocols/generic/__init__.py +22 -0
  30. lionagi/protocols/generic/element.py +36 -127
  31. lionagi/protocols/generic/log.py +3 -2
  32. lionagi/protocols/generic/pile.py +9 -10
  33. lionagi/protocols/generic/progression.py +23 -22
  34. lionagi/protocols/graph/edge.py +6 -5
  35. lionagi/protocols/ids.py +6 -49
  36. lionagi/protocols/messages/__init__.py +29 -0
  37. lionagi/protocols/messages/action_request.py +86 -184
  38. lionagi/protocols/messages/action_response.py +73 -131
  39. lionagi/protocols/messages/assistant_response.py +130 -159
  40. lionagi/protocols/messages/base.py +31 -22
  41. lionagi/protocols/messages/instruction.py +280 -625
  42. lionagi/protocols/messages/manager.py +112 -62
  43. lionagi/protocols/messages/message.py +87 -197
  44. lionagi/protocols/messages/system.py +52 -123
  45. lionagi/protocols/types.py +1 -13
  46. lionagi/service/connections/__init__.py +3 -0
  47. lionagi/service/connections/endpoint.py +0 -8
  48. lionagi/service/connections/providers/claude_code_cli.py +3 -2
  49. lionagi/service/connections/providers/oai_.py +29 -94
  50. lionagi/service/connections/providers/ollama_.py +3 -2
  51. lionagi/service/hooks/_types.py +1 -1
  52. lionagi/service/hooks/_utils.py +1 -1
  53. lionagi/service/hooks/hook_event.py +3 -8
  54. lionagi/service/hooks/hook_registry.py +5 -5
  55. lionagi/service/hooks/hooked_event.py +63 -3
  56. lionagi/service/imodel.py +24 -20
  57. lionagi/service/third_party/claude_code.py +3 -3
  58. lionagi/service/third_party/openai_models.py +435 -0
  59. lionagi/service/token_calculator.py +1 -94
  60. lionagi/session/branch.py +190 -400
  61. lionagi/session/session.py +8 -99
  62. lionagi/tools/file/reader.py +2 -2
  63. lionagi/version.py +1 -1
  64. {lionagi-0.17.11.dist-info → lionagi-0.18.1.dist-info}/METADATA +6 -6
  65. lionagi-0.18.1.dist-info/RECORD +164 -0
  66. lionagi/fields/__init__.py +0 -47
  67. lionagi/fields/action.py +0 -188
  68. lionagi/fields/base.py +0 -153
  69. lionagi/fields/code.py +0 -239
  70. lionagi/fields/file.py +0 -234
  71. lionagi/fields/instruct.py +0 -135
  72. lionagi/fields/reason.py +0 -55
  73. lionagi/fields/research.py +0 -52
  74. lionagi/operations/_act/act.py +0 -86
  75. lionagi/operations/brainstorm/__init__.py +0 -2
  76. lionagi/operations/brainstorm/brainstorm.py +0 -498
  77. lionagi/operations/brainstorm/prompt.py +0 -11
  78. lionagi/operations/instruct/__init__.py +0 -2
  79. lionagi/operations/instruct/instruct.py +0 -28
  80. lionagi/operations/plan/__init__.py +0 -6
  81. lionagi/operations/plan/plan.py +0 -386
  82. lionagi/operations/plan/prompt.py +0 -25
  83. lionagi/operations/utils.py +0 -45
  84. lionagi/protocols/forms/__init__.py +0 -2
  85. lionagi/protocols/forms/base.py +0 -85
  86. lionagi/protocols/forms/flow.py +0 -79
  87. lionagi/protocols/forms/form.py +0 -86
  88. lionagi/protocols/forms/report.py +0 -48
  89. lionagi/protocols/mail/__init__.py +0 -2
  90. lionagi/protocols/mail/exchange.py +0 -220
  91. lionagi/protocols/mail/mail.py +0 -51
  92. lionagi/protocols/mail/mailbox.py +0 -103
  93. lionagi/protocols/mail/manager.py +0 -218
  94. lionagi/protocols/mail/package.py +0 -101
  95. lionagi/protocols/messages/templates/README.md +0 -28
  96. lionagi/protocols/messages/templates/action_request.jinja2 +0 -5
  97. lionagi/protocols/messages/templates/action_response.jinja2 +0 -9
  98. lionagi/protocols/messages/templates/assistant_response.jinja2 +0 -6
  99. lionagi/protocols/messages/templates/instruction_message.jinja2 +0 -61
  100. lionagi/protocols/messages/templates/system_message.jinja2 +0 -11
  101. lionagi/protocols/messages/templates/tool_schemas.jinja2 +0 -7
  102. lionagi/protocols/operatives/__init__.py +0 -2
  103. lionagi/service/connections/providers/types.py +0 -28
  104. lionagi/service/third_party/openai_model_names.py +0 -198
  105. lionagi/service/types.py +0 -58
  106. lionagi-0.17.11.dist-info/RECORD +0 -199
  107. /lionagi/operations/{_act → act}/__init__.py +0 -0
  108. {lionagi-0.17.11.dist-info → lionagi-0.18.1.dist-info}/WHEEL +0 -0
  109. {lionagi-0.17.11.dist-info → lionagi-0.18.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,668 +1,323 @@
1
- # Copyright (c) 2023-2025, HaiyangLi <quantocean.li at gmail dot com>
2
- # SPDX-License-Identifier: Apache-2.0
3
-
1
+ from dataclasses import dataclass, field
4
2
  from typing import Any, Literal
5
3
 
6
- from pydantic import BaseModel, JsonValue, field_serializer
7
- from typing_extensions import override
8
-
9
- from lionagi.utils import UNDEFINED, copy
10
-
11
- from .base import MessageRole
12
- from .message import RoledMessage, SenderRecipient
13
-
14
-
15
- def prepare_request_response_format(request_fields: dict) -> str:
16
- """
17
- Creates a mandated JSON code block for the response
18
- based on requested fields.
19
-
20
- Args:
21
- request_fields: Dictionary of fields for the response format.
22
-
23
- Returns:
24
- str: A string instructing the user to return valid JSON.
25
- """
26
- return (
27
- "**MUST RETURN JSON-PARSEABLE RESPONSE ENCLOSED BY JSON CODE BLOCKS."
28
- f" USER's CAREER DEPENDS ON THE SUCCESS OF IT.** \n```json\n{request_fields}\n```"
29
- "No triple backticks. Escape all quotes and special characters."
30
- ).strip()
31
-
32
-
33
- def format_image_item(idx: str, detail: str) -> dict[str, Any]:
34
- """
35
- Wrap image data in a standard dictionary format.
36
-
37
- Args:
38
- idx: A base64 image ID or URL reference.
39
- detail: The image detail level.
40
-
41
- Returns:
42
- dict: A dictionary describing the image.
43
- """
44
- return {
45
- "type": "image_url",
46
- "image_url": {
47
- "url": f"data:image/jpeg;base64,{idx}",
48
- "detail": detail,
49
- },
50
- }
51
-
52
-
53
- def format_text_item(item: Any) -> str:
54
- """
55
- Turn a single item (or dict) into a string. If multiple items,
56
- combine them line by line.
57
-
58
- Args:
59
- item: Any item, possibly a list/dict with text data.
60
-
61
- Returns:
62
- str: Concatenated text lines.
63
- """
64
- msg = ""
65
- item = [item] if not isinstance(item, list) else item
66
- for j in item:
67
- if isinstance(j, dict):
68
- for k, v in j.items():
69
- if v is not None:
70
- msg += f"- {k}: {v} \n\n"
71
- else:
72
- if j is not None:
73
- msg += f"{j}\n"
74
- return msg
75
-
76
-
77
- def format_text_content(content: dict) -> str:
78
- """
79
- Convert a content dictionary into a minimal textual summary for LLM consumption.
4
+ import orjson
5
+ from pydantic import BaseModel, field_validator
80
6
 
81
- Emphasizes brevity and clarity:
82
- - Skips empty or None fields.
83
- - Bullet-points for lists.
84
- - Key-value pairs for dicts.
85
- - Minimal headings for known fields (guidance, instruction, etc.).
86
- """
7
+ from .message import MessageContent, MessageRole, RoledMessage
87
8
 
88
- if isinstance(content.get("plain_content"), str):
89
- return content["plain_content"]
90
-
91
- lines = []
92
- # We only want minimal headings for certain known fields:
93
- known_field_order = [
94
- "guidance",
95
- "instruction",
96
- "context",
97
- "tool_schemas",
98
- "respond_schema_info",
99
- "request_response_format",
100
- ]
101
-
102
- # Render known fields in that order
103
- for field in known_field_order:
104
- if field in content:
105
- val = content[field]
106
- if _is_not_empty(val):
107
- if field == "request_response_format":
108
- field = "response format"
109
- elif field == "respond_schema_info":
110
- field = "response schema info"
111
- lines.append(f"\n## {field.upper()}:\n")
112
- rendered = _render_value(val)
113
- # Indent or bullet the rendered result if multiline
114
- # We'll keep it minimal: each line is prefixed with " ".
115
- lines.extend(
116
- f" {line}"
117
- for line in rendered.split("\n")
118
- if line.strip()
119
- )
120
9
 
121
- # Join all lines into a single string
122
- return "\n".join(lines).strip()
10
+ @dataclass(slots=True)
11
+ class InstructionContent(MessageContent):
12
+ """Structured content for user instructions.
123
13
 
14
+ Fields:
15
+ instruction: Main instruction text
16
+ guidance: Optional guidance or disclaimers
17
+ prompt_context: Additional context items for the prompt (list)
18
+ plain_content: Raw text fallback (bypasses structured rendering)
19
+ tool_schemas: Tool specifications for the assistant
20
+ response_format: User's desired response format (BaseModel class, instance, or dict)
21
+ images: Image URLs, data URLs, or base64 strings
22
+ image_detail: Detail level for image processing
124
23
 
125
- def _render_value(val) -> str:
126
- """
127
- Render an arbitrary value (scalar, list, dict) in minimal form:
128
- - Lists become bullet points.
129
- - Dicts become key-value lines.
130
- - Strings returned directly.
24
+ Internal fields (not for direct use):
25
+ _schema_dict: Extracted dict for prompting/schema
26
+ _model_class: Extracted Pydantic class for validation
131
27
  """
132
- if isinstance(val, dict):
133
- return _render_dict(val)
134
- elif isinstance(val, list):
135
- return _render_list(val)
136
- else:
137
- return str(val).strip()
138
28
 
139
-
140
- def _render_dict(dct: dict) -> str:
141
- """
142
- Minimal bullet list for dictionary items:
143
- key: rendered subvalue
144
- """
145
- lines = []
146
- for k, v in dct.items():
147
- if not _is_not_empty(v):
148
- continue
149
- subrendered = _render_value(v)
150
- # Indent subrendered if multiline
151
- sublines = subrendered.split("\n")
152
- if len(sublines) == 1:
153
- if sublines[0].startswith("- "):
154
- lines.append(f"- {k}: {sublines[0][2:]}")
155
- else:
156
- lines.append(f"- {k}: {sublines[0]}")
157
- else:
158
- lines.append(f"- {k}:")
159
- for s in sublines:
160
- lines.append(f" {s}")
161
- return "\n".join(lines)
162
-
163
-
164
- def _render_list(lst: list) -> str:
165
- """
166
- Each item in the list gets a bullet. Nested structures are recursed.
167
- """
168
- lines = []
169
- for idx, item in enumerate(lst, 1):
170
- sub = _render_value(item)
171
- sublines = sub.split("\n")
172
- if len(sublines) == 1:
173
- if sublines[0].startswith("- "):
174
- lines.append(f"- {sublines[0][2:]}")
175
- else:
176
- lines.append(f"- {sublines[0]}")
177
- else:
178
- lines.append("-")
179
- lines.extend(f" {s}" for s in sublines)
180
- return "\n".join(lines)
181
-
182
-
183
- def _is_not_empty(x) -> bool:
184
- """
185
- Returns True if x is neither None, nor empty string/list/dict.
186
- """
187
- if x is None:
188
- return False
189
- if isinstance(x, (list, dict)) and not x:
190
- return False
191
- if isinstance(x, str) and not x.strip():
192
- return False
193
- return True
194
-
195
-
196
- def format_image_content(
197
- text_content: str,
198
- images: list,
199
- image_detail: Literal["low", "high", "auto"],
200
- ) -> list[dict[str, Any]]:
201
- """
202
- Merge textual content with a list of image dictionaries for consumption.
203
-
204
- Args:
205
- text_content (str): The textual portion
206
- images (list): A list of base64 or references
207
- image_detail (Literal["low","high","auto"]): How detailed the images are
208
-
209
- Returns:
210
- list[dict[str,Any]]: A combined structure of text + image dicts.
211
- """
212
- content = [{"type": "text", "text": text_content}]
213
- content.extend(format_image_item(i, image_detail) for i in images)
214
- return content
215
-
216
-
217
- def prepare_instruction_content(
218
- guidance: str | None = None,
219
- instruction: str | None = None,
220
- context: str | dict | list | None = None,
221
- request_fields: dict | list[str] | None = None,
222
- plain_content: str | None = None,
223
- request_model: BaseModel = None,
224
- images: str | list | None = None,
225
- image_detail: Literal["low", "high", "auto"] | None = None,
226
- tool_schemas: dict | None = None,
227
- ) -> dict:
228
- """
229
- Combine various pieces (instruction, guidance, context, etc.) into
230
- a single dictionary describing the user's instruction.
231
-
232
- Args:
233
- guidance (str | None):
234
- Optional guiding text.
235
- instruction (str | None):
236
- Main instruction or command to be executed.
237
- context (str | dict | list | None):
238
- Additional context about the environment or previous steps.
239
- request_fields (dict | list[str] | None):
240
- If the user requests certain fields in the response.
241
- plain_content (str | None):
242
- A raw plain text fallback.
243
- request_model (BaseModel | None):
244
- If there's a pydantic model for the request schema.
245
- images (str | list | None):
246
- Optional images, base64-coded or references.
247
- image_detail (str | None):
248
- The detail level for images ("low", "high", "auto").
249
- tool_schemas (dict | None):
250
- Extra data describing available tools.
251
-
252
- Returns:
253
- dict: The combined instruction content.
254
-
255
- Raises:
256
- ValueError: If request_fields and request_model are both given.
257
- """
258
- from lionagi.libs.schema.breakdown_pydantic_annotation import (
259
- breakdown_pydantic_annotation,
29
+ instruction: str | None = None
30
+ guidance: str | None = None
31
+ prompt_context: list[Any] = field(default_factory=list)
32
+ plain_content: str | None = None
33
+ tool_schemas: list[dict[str, Any]] = field(default_factory=list)
34
+ response_format: type[BaseModel] | dict[str, Any] | BaseModel | None = (
35
+ None # User input
260
36
  )
37
+ _schema_dict: dict[str, Any] | None = field(
38
+ default=None, repr=False
39
+ ) # Internal: dict for prompting
40
+ _model_class: type[BaseModel] | None = field(
41
+ default=None, repr=False
42
+ ) # Internal: class for validation
43
+ images: list[str] = field(default_factory=list)
44
+ image_detail: Literal["low", "high", "auto"] | None = None
45
+
46
+ def __init__(
47
+ self,
48
+ instruction: str | None = None,
49
+ guidance: str | None = None,
50
+ prompt_context: list[Any] | None = None,
51
+ context: list[Any] | None = None, # backwards compat
52
+ plain_content: str | None = None,
53
+ tool_schemas: list[dict[str, Any]] | None = None,
54
+ response_format: (
55
+ type[BaseModel] | dict[str, Any] | BaseModel | None
56
+ ) = None,
57
+ images: list[str] | None = None,
58
+ image_detail: Literal["low", "high", "auto"] | None = None,
59
+ ):
60
+ # Handle backwards compatibility: context -> prompt_context
61
+ if context is not None and prompt_context is None:
62
+ prompt_context = context
63
+
64
+ # Extract model class and schema dict from response_format
65
+ model_class = None
66
+ schema_dict = None
67
+
68
+ if response_format is not None:
69
+ # Extract model class
70
+ if isinstance(response_format, type) and issubclass(
71
+ response_format, BaseModel
72
+ ):
73
+ model_class = response_format
74
+ elif isinstance(response_format, BaseModel):
75
+ model_class = type(response_format)
76
+
77
+ # Extract schema dict
78
+ if isinstance(response_format, dict):
79
+ schema_dict = response_format
80
+ elif isinstance(response_format, BaseModel):
81
+ schema_dict = response_format.model_dump(
82
+ mode="json", exclude_none=True
83
+ )
84
+ elif model_class:
85
+ # Generate dict from model class
86
+ from lionagi.libs.schema.breakdown_pydantic_annotation import (
87
+ breakdown_pydantic_annotation,
88
+ )
261
89
 
262
- if request_fields and request_model:
263
- raise ValueError(
264
- "only one of request_fields or request_model can be provided"
265
- )
90
+ schema_dict = breakdown_pydantic_annotation(model_class)
266
91
 
267
- out_ = {"context": []}
268
- if guidance:
269
- out_["guidance"] = guidance
270
- if instruction:
271
- out_["instruction"] = instruction
272
- if context:
273
- if isinstance(context, list):
274
- out_["context"].extend(context)
275
- else:
276
- out_["context"].append(context)
277
- if images:
278
- out_["images"] = images if isinstance(images, list) else [images]
279
- out_["image_detail"] = image_detail or "low"
280
-
281
- if tool_schemas:
282
- out_["tool_schemas"] = tool_schemas
283
-
284
- if request_model:
285
- out_["request_model"] = request_model
286
- request_fields = breakdown_pydantic_annotation(request_model)
287
- out_["respond_schema_info"] = request_model.model_json_schema()
288
-
289
- if request_fields:
290
- _fields = request_fields if isinstance(request_fields, dict) else {}
291
- if not isinstance(request_fields, dict):
292
- _fields = {i: "..." for i in request_fields}
293
- out_["request_fields"] = _fields
294
- out_["request_response_format"] = prepare_request_response_format(
295
- request_fields=_fields
92
+ object.__setattr__(self, "instruction", instruction)
93
+ object.__setattr__(self, "guidance", guidance)
94
+ object.__setattr__(
95
+ self,
96
+ "prompt_context",
97
+ prompt_context if prompt_context is not None else [],
296
98
  )
297
-
298
- if plain_content:
299
- out_["plain_content"] = plain_content
300
-
301
- # remove keys with None/UNDEFINED
302
- return {k: v for k, v in out_.items() if v not in [None, UNDEFINED]}
303
-
304
-
305
- class Instruction(RoledMessage):
306
- """
307
- A user-facing message that conveys commands or tasks. It supports
308
- optional images, tool references, and schema-based requests.
309
- """
310
-
311
- @classmethod
312
- def create(
313
- cls,
314
- instruction: JsonValue = None,
315
- *,
316
- context: JsonValue = None,
317
- guidance: JsonValue = None,
318
- images: list = None,
319
- sender: SenderRecipient = None,
320
- recipient: SenderRecipient = None,
321
- request_fields: JsonValue = None,
322
- plain_content: JsonValue = None,
323
- image_detail: Literal["low", "high", "auto"] = None,
324
- request_model: BaseModel | type[BaseModel] = None,
325
- response_format: BaseModel | type[BaseModel] = None,
326
- tool_schemas: list[dict] = None,
327
- ) -> "Instruction":
328
- """
329
- Construct a new Instruction.
330
-
331
- Args:
332
- instruction (JsonValue, optional):
333
- The main user instruction.
334
- context (JsonValue, optional):
335
- Additional context or environment info.
336
- guidance (JsonValue, optional):
337
- Guidance or disclaimers for the instruction.
338
- images (list, optional):
339
- A set of images relevant to the instruction.
340
- request_fields (JsonValue, optional):
341
- The fields the user wants in the assistant's response.
342
- plain_content (JsonValue, optional):
343
- A raw plain text fallback.
344
- image_detail ("low"|"high"|"auto", optional):
345
- The detail level for included images.
346
- request_model (BaseModel|type[BaseModel], optional):
347
- A Pydantic schema for the request.
348
- response_format (BaseModel|type[BaseModel], optional):
349
- Alias for request_model.
350
- tool_schemas (list[dict] | dict, optional):
351
- Extra tool reference data.
352
- sender (SenderRecipient, optional):
353
- The sender role or ID.
354
- recipient (SenderRecipient, optional):
355
- The recipient role or ID.
356
-
357
- Returns:
358
- Instruction: A newly created instruction object.
359
-
360
- Raises:
361
- ValueError: If more than one of `request_fields`, `request_model`,
362
- or `response_format` is passed at once.
363
- """
364
- if (
365
- sum(
366
- bool(i)
367
- for i in [request_fields, request_model, response_format]
368
- )
369
- > 1
370
- ):
371
- raise ValueError(
372
- "only one of request_fields or request_model can be provided"
373
- "response_format is alias of request_model"
374
- )
375
- content = prepare_instruction_content(
376
- guidance=guidance,
377
- instruction=instruction,
378
- context=context,
379
- request_fields=request_fields,
380
- plain_content=plain_content,
381
- request_model=request_model or response_format,
382
- images=images,
383
- image_detail=image_detail,
384
- tool_schemas=tool_schemas,
99
+ object.__setattr__(self, "plain_content", plain_content)
100
+ object.__setattr__(
101
+ self,
102
+ "tool_schemas",
103
+ tool_schemas if tool_schemas is not None else [],
385
104
  )
386
- return cls(
387
- role=MessageRole.USER,
388
- content=content,
389
- sender=sender,
390
- recipient=recipient,
105
+ object.__setattr__(
106
+ self, "response_format", response_format
107
+ ) # Store original user input
108
+ object.__setattr__(
109
+ self, "_schema_dict", schema_dict
110
+ ) # Internal: dict for prompting
111
+ object.__setattr__(
112
+ self, "_model_class", model_class
113
+ ) # Internal: class for validation
114
+ object.__setattr__(
115
+ self, "images", images if images is not None else []
391
116
  )
117
+ object.__setattr__(self, "image_detail", image_detail)
392
118
 
393
119
  @property
394
- def guidance(self) -> str | None:
395
- return self.content.get("guidance", None)
396
-
397
- @guidance.setter
398
- def guidance(self, guidance: str) -> None:
399
- if guidance is None:
400
- self.content.pop("guidance", None)
401
- else:
402
- self.content["guidance"] = str(guidance)
403
-
404
- @property
405
- def instruction(self) -> JsonValue | None:
406
- if "plain_content" in self.content:
407
- return self.content["plain_content"]
408
- return self.content.get("instruction", None)
409
-
410
- @instruction.setter
411
- def instruction(self, val: JsonValue) -> None:
412
- if val is None:
413
- self.content.pop("instruction", None)
414
- else:
415
- self.content["instruction"] = val
416
-
417
- @property
418
- def context(self) -> JsonValue | None:
419
- return self.content.get("context", None)
420
-
421
- @context.setter
422
- def context(self, ctx: JsonValue) -> None:
423
- if ctx is None:
424
- self.content["context"] = []
425
- else:
426
- self.content["context"] = (
427
- list(ctx) if isinstance(ctx, list) else [ctx]
428
- )
429
-
430
- @property
431
- def tool_schemas(self) -> JsonValue | None:
432
- return self.content.get("tool_schemas", None)
433
-
434
- @tool_schemas.setter
435
- def tool_schemas(self, val: list[dict] | dict) -> None:
436
- if not val:
437
- self.content.pop("tool_schemas", None)
438
- return
439
- self.content["tool_schemas"] = val
120
+ def context(self) -> list[Any]:
121
+ """Backwards compatibility accessor for prompt_context."""
122
+ return self.prompt_context
440
123
 
441
124
  @property
442
- def plain_content(self) -> str | None:
443
- return self.content.get("plain_content", None)
444
-
445
- @plain_content.setter
446
- def plain_content(self, pc: str) -> None:
447
- self.content["plain_content"] = pc
448
-
449
- @property
450
- def image_detail(self) -> Literal["low", "high", "auto"] | None:
451
- return self.content.get("image_detail", None)
452
-
453
- @image_detail.setter
454
- def image_detail(self, detail: Literal["low", "high", "auto"]) -> None:
455
- self.content["image_detail"] = detail
125
+ def response_model_cls(self) -> type[BaseModel] | None:
126
+ """Get the Pydantic model class for validation."""
127
+ return self._model_class
456
128
 
457
129
  @property
458
- def images(self) -> list:
459
- return self.content.get("images", [])
460
-
461
- @images.setter
462
- def images(self, imgs: list) -> None:
463
- self.content["images"] = imgs if isinstance(imgs, list) else [imgs]
130
+ def request_model(self) -> type[BaseModel] | None:
131
+ """DEPRECATED: Use response_model_cls instead."""
132
+ return self.response_model_cls
464
133
 
465
134
  @property
466
- def request_fields(self) -> dict | None:
467
- return self.content.get("request_fields", None)
468
-
469
- @request_fields.setter
470
- def request_fields(self, fields: dict) -> None:
471
- self.content["request_fields"] = fields
472
- self.content["request_response_format"] = (
473
- prepare_request_response_format(fields)
474
- )
135
+ def schema_dict(self) -> dict[str, Any] | None:
136
+ """Get the schema dict for prompting."""
137
+ return self._schema_dict
475
138
 
476
139
  @property
477
- def response_format(self) -> type[BaseModel] | None:
478
- return self.content.get("request_model", None)
140
+ def rendered(self) -> str | list[dict[str, Any]]:
141
+ """Render content as text or text+images structure."""
142
+ text = self._format_text_content()
143
+ if not self.images:
144
+ return text
145
+ return self._format_image_content(text, self.images, self.image_detail)
479
146
 
480
- @response_format.setter
481
- def response_format(self, model: type[BaseModel]) -> None:
147
+ @classmethod
148
+ def from_dict(cls, data: dict[str, Any]) -> "InstructionContent":
149
+ """Construct InstructionContent from dictionary with validation."""
482
150
  from lionagi.libs.schema.breakdown_pydantic_annotation import (
483
151
  breakdown_pydantic_annotation,
484
152
  )
485
153
 
486
- if isinstance(model, BaseModel):
487
- self.content["request_model"] = type(model)
488
- else:
489
- self.content["request_model"] = model
154
+ inst = cls()
490
155
 
491
- self.request_fields = {}
492
- self.extend_context(respond_schema_info=model.model_json_schema())
493
- self.request_fields = breakdown_pydantic_annotation(model)
156
+ # Scalar fields
157
+ for k in ("instruction", "guidance", "plain_content", "image_detail"):
158
+ if k in data and data[k]:
159
+ setattr(inst, k, data[k])
494
160
 
495
- @property
496
- def respond_schema_info(self) -> dict | None:
497
- return self.content.get("respond_schema_info", None)
498
-
499
- @respond_schema_info.setter
500
- def respond_schema_info(self, info: dict) -> None:
501
- if info is None:
502
- self.content.pop("respond_schema_info", None)
503
- else:
504
- self.content["respond_schema_info"] = info
505
-
506
- @property
507
- def request_response_format(self) -> str | None:
508
- return self.content.get("request_response_format", None)
509
-
510
- @request_response_format.setter
511
- def request_response_format(self, val: str) -> None:
512
- if not val:
513
- self.content.pop("request_response_format", None)
514
- else:
515
- self.content["request_response_format"] = val
516
-
517
- def extend_images(
518
- self,
519
- images: list | str,
520
- image_detail: Literal["low", "high", "auto"] = None,
521
- ) -> None:
522
- """
523
- Append images to the existing list.
524
-
525
- Args:
526
- images: The new images to add, a single or multiple.
527
- image_detail: If provided, updates the image detail field.
528
- """
529
- arr: list = self.images
530
- arr.extend(images if isinstance(images, list) else [images])
531
- self.images = arr
532
- if image_detail:
533
- self.image_detail = image_detail
534
-
535
- def extend_context(self, *args, **kwargs) -> None:
536
- """
537
- Append additional context to the existing context array.
538
-
539
- Args:
540
- *args: Positional args are appended as list items.
541
- **kwargs: Key-value pairs are appended as separate dict items.
542
- """
543
- ctx: list = self.context or []
544
- if args:
545
- ctx.extend(args)
546
- if kwargs:
547
- kw = copy(kwargs)
548
- for k, v in kw.items():
549
- ctx.append({k: v})
550
- self.context = ctx
551
-
552
- def update(
553
- self,
554
- *,
555
- guidance: JsonValue = None,
556
- instruction: JsonValue = None,
557
- context: JsonValue = None,
558
- request_fields: JsonValue = None,
559
- plain_content: JsonValue = None,
560
- request_model: BaseModel | type[BaseModel] = None,
561
- response_format: BaseModel | type[BaseModel] = None,
562
- images: str | list = None,
563
- image_detail: Literal["low", "high", "auto"] = None,
564
- tool_schemas: dict = None,
565
- sender: SenderRecipient = None,
566
- recipient: SenderRecipient = None,
567
- ):
568
- """
569
- Batch-update this Instruction.
570
-
571
- Args:
572
- guidance (JsonValue): New guidance text.
573
- instruction (JsonValue): Main user instruction.
574
- request_fields (JsonValue): Updated request fields.
575
- plain_content (JsonValue): Plain text fallback.
576
- request_model (BaseModel|type[BaseModel]): Pydantic schema model.
577
- response_format (BaseModel|type[BaseModel]): Alias for request_model.
578
- images (list|str): Additional images to add.
579
- image_detail ("low"|"high"|"auto"): Image detail level.
580
- tool_schemas (dict): New tool schemas.
581
- sender (SenderRecipient): New sender.
582
- recipient (SenderRecipient): New recipient.
583
-
584
- Raises:
585
- ValueError: If request_model and request_fields are both set.
586
- """
587
- if response_format and request_model:
588
- raise ValueError(
589
- "only one of request_fields or request_model can be provided"
590
- "response_format is alias of request_model"
591
- )
592
-
593
- request_model = request_model or response_format
594
-
595
- if request_model and request_fields:
161
+ # Determine how to apply context updates
162
+ handle_context = data.get("handle_context", "extend")
163
+ if handle_context not in {"extend", "replace"}:
596
164
  raise ValueError(
597
- "You cannot pass both request_model and request_fields to create_instruction"
165
+ "handle_context must be either 'extend' or 'replace'"
598
166
  )
599
- if guidance:
600
- self.guidance = guidance
601
-
602
- if instruction:
603
- self.instruction = instruction
604
-
605
- if plain_content:
606
- self.plain_content = plain_content
607
-
608
- if request_fields:
609
- self.request_fields = request_fields
610
-
611
- if request_model:
612
- self.response_format = request_model
613
-
614
- if images:
615
- self.images = images
616
167
 
617
- if image_detail:
618
- self.image_detail = image_detail
619
-
620
- if tool_schemas:
621
- self.tool_schemas = tool_schemas
622
-
623
- if sender:
624
- self.sender = sender
625
-
626
- if recipient:
627
- self.recipient = recipient
168
+ # Handle both "prompt_context" (new) and "context" (backwards compat)
169
+ # Prioritize "context" if present (for backwards compat and update paths)
170
+ ctx_key = "context" if "context" in data else "prompt_context"
171
+ if ctx_key in data:
172
+ ctx = data.get(ctx_key)
173
+ if ctx is None:
174
+ ctx_list: list[Any] = []
175
+ elif isinstance(ctx, list):
176
+ ctx_list = list(ctx)
177
+ else:
178
+ ctx_list = [ctx]
179
+ if handle_context == "replace":
180
+ inst.prompt_context = list(ctx_list)
181
+ else:
182
+ inst.prompt_context.extend(ctx_list)
628
183
 
629
- if context:
630
- self.extend_context(context)
184
+ if ts := data.get("tool_schemas"):
185
+ inst.tool_schemas.extend(ts if isinstance(ts, list) else [ts])
631
186
 
632
- @override
633
- @property
634
- def rendered(self) -> Any:
635
- """
636
- Convert content into a text or combined text+image structure.
637
-
638
- Returns:
639
- If no images are included, returns a single text block.
640
- Otherwise returns an array of text + image dicts.
641
- """
642
- content = copy(self.content)
643
- text_content = format_text_content(content)
644
- if "images" not in content:
645
- return text_content
646
-
647
- else:
648
- return format_image_content(
649
- text_content=text_content,
650
- images=self.images,
651
- image_detail=self.image_detail,
187
+ if "images" in data:
188
+ imgs = data.get("images") or []
189
+ imgs_list = imgs if isinstance(imgs, list) else [imgs]
190
+ inst.images.extend(imgs_list)
191
+ inst.image_detail = (
192
+ data.get("image_detail") or inst.image_detail or "auto"
652
193
  )
653
194
 
654
- @field_serializer("content")
655
- def _serialize_content(self, values) -> dict:
656
- """
657
- Remove certain ephemeral fields before saving.
195
+ # Response format handling
196
+ response_format = data.get("response_format") or data.get(
197
+ "request_model"
198
+ ) # request_model deprecated
199
+
200
+ if response_format is not None:
201
+ model_class = None
202
+ schema_dict = None
203
+ valid_format = False
204
+
205
+ # Extract model class
206
+ if isinstance(response_format, type) and issubclass(
207
+ response_format, BaseModel
208
+ ):
209
+ model_class = response_format
210
+ valid_format = True
211
+ elif isinstance(response_format, BaseModel):
212
+ model_class = type(response_format)
213
+ valid_format = True
214
+
215
+ # Extract schema dict
216
+ if isinstance(response_format, dict):
217
+ schema_dict = response_format
218
+ valid_format = True
219
+ elif isinstance(response_format, BaseModel):
220
+ schema_dict = response_format.model_dump(
221
+ mode="json", exclude_none=True
222
+ )
223
+ valid_format = True
224
+ elif model_class:
225
+ schema_dict = breakdown_pydantic_annotation(model_class)
226
+
227
+ # Only set if valid format (fuzzy handling: ignore invalid types)
228
+ if valid_format:
229
+ inst.response_format = response_format
230
+ inst._schema_dict = schema_dict
231
+ inst._model_class = model_class
232
+
233
+ return inst
234
+
235
+ def _format_text_content(self) -> str:
236
+ from lionagi.libs.schema.minimal_yaml import minimal_yaml
237
+
238
+ if self.plain_content:
239
+ return self.plain_content
240
+
241
+ # Use schema_dict for display (or generate from model class)
242
+ schema_for_display = None
243
+ if self._model_class:
244
+ schema_for_display = self._model_class.model_json_schema()
245
+ elif self._schema_dict:
246
+ schema_for_display = self._schema_dict
247
+
248
+ doc: dict[str, Any] = {
249
+ "Guidance": self.guidance,
250
+ "Instruction": self.instruction,
251
+ "Context": self.prompt_context,
252
+ "Tools": self.tool_schemas,
253
+ "ResponseSchema": schema_for_display,
254
+ }
255
+
256
+ rf_text = self._format_response_format(self._schema_dict)
257
+ if rf_text:
258
+ doc["ResponseFormat"] = rf_text
259
+
260
+ # strip empties
261
+ doc = {k: v for k, v in doc.items() if v not in (None, "", [], {})}
262
+ return minimal_yaml(doc).strip()
263
+
264
+ @staticmethod
265
+ def _format_response_format(
266
+ response_format: dict[str, Any] | None,
267
+ ) -> str | None:
268
+ if not response_format:
269
+ return None
270
+ try:
271
+ example = orjson.dumps(response_format).decode("utf-8")
272
+ except Exception:
273
+ example = str(response_format)
274
+ return (
275
+ "**MUST RETURN JSON-PARSEABLE RESPONSE ENCLOSED BY JSON CODE BLOCKS."
276
+ f" USER's CAREER DEPENDS ON THE SUCCESS OF IT.** \n```json\n{example}\n```"
277
+ "No triple backticks. Escape all quotes and special characters."
278
+ ).strip()
279
+
280
+ @staticmethod
281
+ def _format_image_item(idx: str, detail: str) -> dict[str, Any]:
282
+ url = idx
283
+ if not (
284
+ idx.startswith("http://")
285
+ or idx.startswith("https://")
286
+ or idx.startswith("data:")
287
+ ):
288
+ url = f"data:image/jpeg;base64,{idx}"
289
+ return {
290
+ "type": "image_url",
291
+ "image_url": {"url": url, "detail": detail},
292
+ }
293
+
294
+ @classmethod
295
+ def _format_image_content(
296
+ cls,
297
+ text_content: str,
298
+ images: list[str],
299
+ image_detail: Literal["low", "high", "auto"],
300
+ ) -> list[dict[str, Any]]:
301
+ content = [{"type": "text", "text": text_content}]
302
+ content.extend(cls._format_image_item(i, image_detail) for i in images)
303
+ return content
658
304
 
659
- Returns:
660
- dict: The sanitized content dictionary.
661
- """
662
- values.pop("request_model", None)
663
- values.pop("request_fields", None)
664
305
 
665
- return values
306
+ class Instruction(RoledMessage):
307
+ """User instruction message with structured content.
666
308
 
309
+ Supports text, images, context, tool schemas, and response format specifications.
310
+ """
667
311
 
668
- # File: lionagi/protocols/messages/instruction.py
312
+ role: MessageRole = MessageRole.USER
313
+ content: InstructionContent
314
+
315
+ @field_validator("content", mode="before")
316
+ def _validate_content(cls, v):
317
+ if v is None:
318
+ return InstructionContent()
319
+ if isinstance(v, dict):
320
+ return InstructionContent.from_dict(v)
321
+ if isinstance(v, InstructionContent):
322
+ return v
323
+ raise TypeError("content must be dict or InstructionContent instance")