@librechat/agents 3.0.0-rc6 → 3.0.0-rc8
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.
- package/dist/cjs/graphs/Graph.cjs +0 -1
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +0 -7
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/stream.cjs +1 -1
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +0 -1
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +0 -7
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/stream.mjs +1 -1
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/types/scripts/test-handoff-input.d.ts +0 -1
- package/dist/types/scripts/test-tools-before-handoff.d.ts +1 -0
- package/package.json +3 -1
- package/src/graphs/Graph.ts +0 -1
- package/src/graphs/MultiAgentGraph.ts +0 -8
- package/src/scripts/test-handoff-input.ts +65 -5
- package/src/scripts/test-tools-before-handoff.ts +233 -0
- package/src/stream.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@librechat/agents",
|
|
3
|
-
"version": "3.0.00-
|
|
3
|
+
"version": "3.0.00-rc8",
|
|
4
4
|
"main": "./dist/cjs/main.cjs",
|
|
5
5
|
"module": "./dist/esm/main.mjs",
|
|
6
6
|
"types": "./dist/types/index.d.ts",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"abort": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/abort.ts --provider 'openAI' --name 'Jo' --location 'New York, NY'",
|
|
58
58
|
"start:cli2": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/cli2.ts --provider 'anthropic' --name 'Jo' --location 'New York, NY'",
|
|
59
59
|
"multi-agent-test": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-test.ts",
|
|
60
|
+
"test-tools-before-handoff": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/test-tools-before-handoff.ts",
|
|
60
61
|
"multi-agent-parallel": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-parallel.ts",
|
|
61
62
|
"multi-agent-sequence": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-sequence.ts",
|
|
62
63
|
"multi-agent-conditional": "node -r dotenv/config --loader ./tsconfig-paths-bootstrap.mjs --experimental-specifier-resolution=node ./src/scripts/multi-agent-conditional.ts",
|
|
@@ -115,6 +116,7 @@
|
|
|
115
116
|
"devDependencies": {
|
|
116
117
|
"@anthropic-ai/vertex-sdk": "^0.12.0",
|
|
117
118
|
"@eslint/compat": "^1.2.7",
|
|
119
|
+
"@langchain/tavily": "^0.1.5",
|
|
118
120
|
"@rollup/plugin-alias": "^5.1.0",
|
|
119
121
|
"@rollup/plugin-commonjs": "^28.0.3",
|
|
120
122
|
"@rollup/plugin-json": "^6.1.0",
|
package/src/graphs/Graph.ts
CHANGED
|
@@ -380,7 +380,6 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
|
|
|
380
380
|
currentTools?: t.GraphTools;
|
|
381
381
|
currentToolMap?: t.ToolMap;
|
|
382
382
|
}): CustomToolNode<t.BaseGraphState> | ToolNode<t.BaseGraphState> {
|
|
383
|
-
// return new ToolNode<t.BaseGraphState>(this.tools);
|
|
384
383
|
return new CustomToolNode<t.BaseGraphState>({
|
|
385
384
|
tools: (currentTools as t.GenericTool[] | undefined) ?? [],
|
|
386
385
|
toolMap: currentToolMap,
|
|
@@ -125,14 +125,6 @@ export class MultiAgentGraph extends StandardGraph {
|
|
|
125
125
|
agentContext.tools = [];
|
|
126
126
|
}
|
|
127
127
|
agentContext.tools.push(...handoffTools);
|
|
128
|
-
|
|
129
|
-
// Update tool map
|
|
130
|
-
for (const tool of handoffTools) {
|
|
131
|
-
if (!agentContext.toolMap) {
|
|
132
|
-
agentContext.toolMap = new Map();
|
|
133
|
-
}
|
|
134
|
-
agentContext.toolMap.set(tool.name, tool);
|
|
135
|
-
}
|
|
136
128
|
}
|
|
137
129
|
}
|
|
138
130
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
1
|
import { config } from 'dotenv';
|
|
4
2
|
config();
|
|
5
3
|
|
|
6
4
|
import { HumanMessage } from '@langchain/core/messages';
|
|
7
5
|
import { Run } from '@/run';
|
|
8
|
-
import { Providers } from '@/common';
|
|
6
|
+
import { Providers, GraphEvents } from '@/common';
|
|
7
|
+
import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
|
|
8
|
+
import { ToolEndHandler, ModelEndHandler } from '@/events';
|
|
9
9
|
import type * as t from '@/types';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -14,8 +14,68 @@ import type * as t from '@/types';
|
|
|
14
14
|
*/
|
|
15
15
|
async function testHandoffInput() {
|
|
16
16
|
console.log('Testing Handoff Input Feature...\n');
|
|
17
|
+
// Set up content aggregator
|
|
18
|
+
const { contentParts, aggregateContent } = createContentAggregator();
|
|
19
|
+
|
|
20
|
+
// Track which specialist role was selected
|
|
21
|
+
let selectedRole = '';
|
|
22
|
+
let roleInstructions = '';
|
|
23
|
+
|
|
24
|
+
// Create custom handlers
|
|
25
|
+
const customHandlers = {
|
|
26
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(),
|
|
27
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
28
|
+
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
|
|
29
|
+
[GraphEvents.ON_RUN_STEP]: {
|
|
30
|
+
handle: (
|
|
31
|
+
event: GraphEvents.ON_RUN_STEP,
|
|
32
|
+
data: t.StreamEventData
|
|
33
|
+
): void => {
|
|
34
|
+
const runStepData = data as any;
|
|
35
|
+
if (runStepData?.name) {
|
|
36
|
+
console.log(`\n[${runStepData.name}] Processing...`);
|
|
37
|
+
}
|
|
38
|
+
aggregateContent({ event, data: data as t.RunStep });
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
[GraphEvents.ON_RUN_STEP_COMPLETED]: {
|
|
42
|
+
handle: (
|
|
43
|
+
event: GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
44
|
+
data: t.StreamEventData
|
|
45
|
+
): void => {
|
|
46
|
+
aggregateContent({
|
|
47
|
+
event,
|
|
48
|
+
data: data as unknown as { result: t.ToolEndEvent },
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
53
|
+
handle: (
|
|
54
|
+
event: GraphEvents.ON_MESSAGE_DELTA,
|
|
55
|
+
data: t.StreamEventData
|
|
56
|
+
): void => {
|
|
57
|
+
console.dir(data, { depth: null });
|
|
58
|
+
aggregateContent({ event, data: data as t.MessageDeltaEvent });
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
[GraphEvents.TOOL_START]: {
|
|
62
|
+
handle: (
|
|
63
|
+
_event: string,
|
|
64
|
+
data: t.StreamEventData,
|
|
65
|
+
metadata?: Record<string, unknown>
|
|
66
|
+
): void => {
|
|
67
|
+
const toolData = data as any;
|
|
68
|
+
if (toolData?.name?.includes('transfer_to_')) {
|
|
69
|
+
const specialist = toolData.name.replace('transfer_to_', '');
|
|
70
|
+
console.log(`\n🔀 Transferring to ${specialist}...`);
|
|
71
|
+
selectedRole = specialist;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
17
76
|
|
|
18
77
|
const runConfig: t.RunConfig = {
|
|
78
|
+
customHandlers,
|
|
19
79
|
runId: `test-handoff-input-${Date.now()}`,
|
|
20
80
|
graphConfig: {
|
|
21
81
|
type: 'multi-agent',
|
|
@@ -76,7 +136,7 @@ async function testHandoffInput() {
|
|
|
76
136
|
|
|
77
137
|
// Test queries that should result in different handoffs with specific instructions
|
|
78
138
|
const testQueries = [
|
|
79
|
-
'Analyze our Q4 sales data and identify the top 3 performing products',
|
|
139
|
+
// 'Analyze our Q4 sales data and identify the top 3 performing products',
|
|
80
140
|
'Write a blog post about the benefits of remote work for software developers',
|
|
81
141
|
];
|
|
82
142
|
|
|
@@ -97,7 +157,7 @@ async function testHandoffInput() {
|
|
|
97
157
|
messages: [new HumanMessage(query)],
|
|
98
158
|
};
|
|
99
159
|
|
|
100
|
-
await run.processStream(inputs, config);
|
|
160
|
+
const finalContentParts = await run.processStream(inputs, config);
|
|
101
161
|
|
|
102
162
|
console.log(`\n${'─'.repeat(60)}`);
|
|
103
163
|
console.log('Notice how the supervisor passes specific instructions');
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { config } from 'dotenv';
|
|
2
|
+
config();
|
|
3
|
+
|
|
4
|
+
import { TavilySearch } from '@langchain/tavily';
|
|
5
|
+
import { HumanMessage, BaseMessage } from '@langchain/core/messages';
|
|
6
|
+
import { Run } from '@/run';
|
|
7
|
+
import { Providers, GraphEvents } from '@/common';
|
|
8
|
+
import { ChatModelStreamHandler, createContentAggregator } from '@/stream';
|
|
9
|
+
import { ToolEndHandler, ModelEndHandler } from '@/events';
|
|
10
|
+
import type * as t from '@/types';
|
|
11
|
+
|
|
12
|
+
const conversationHistory: BaseMessage[] = [];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Test edge case: Agent performs 2 web searches before handing off
|
|
16
|
+
*
|
|
17
|
+
* This tests how the system behaves when an agent with handoff capabilities
|
|
18
|
+
* uses tools before transferring control to another agent.
|
|
19
|
+
*/
|
|
20
|
+
async function testToolsBeforeHandoff() {
|
|
21
|
+
console.log('Testing Tools Before Handoff Edge Case...\n');
|
|
22
|
+
|
|
23
|
+
// Set up content aggregator
|
|
24
|
+
const { contentParts, aggregateContent } = createContentAggregator();
|
|
25
|
+
|
|
26
|
+
// Track tool calls and handoffs
|
|
27
|
+
let toolCallCount = 0;
|
|
28
|
+
let handoffOccurred = false;
|
|
29
|
+
|
|
30
|
+
// Create custom handlers
|
|
31
|
+
const customHandlers = {
|
|
32
|
+
[GraphEvents.TOOL_END]: new ToolEndHandler(undefined, (name?: string) => {
|
|
33
|
+
console.log(`\n✅ Tool completed: ${name}`);
|
|
34
|
+
return true;
|
|
35
|
+
}),
|
|
36
|
+
[GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
|
|
37
|
+
[GraphEvents.CHAT_MODEL_STREAM]: new ChatModelStreamHandler(),
|
|
38
|
+
[GraphEvents.ON_RUN_STEP]: {
|
|
39
|
+
handle: (
|
|
40
|
+
event: GraphEvents.ON_RUN_STEP,
|
|
41
|
+
data: t.StreamEventData
|
|
42
|
+
): void => {
|
|
43
|
+
const runStepData = data as any;
|
|
44
|
+
if (runStepData?.name) {
|
|
45
|
+
console.log(`\n[${runStepData.name}] Processing...`);
|
|
46
|
+
}
|
|
47
|
+
aggregateContent({ event, data: data as t.RunStep });
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
[GraphEvents.ON_RUN_STEP_COMPLETED]: {
|
|
51
|
+
handle: (
|
|
52
|
+
event: GraphEvents.ON_RUN_STEP_COMPLETED,
|
|
53
|
+
data: t.StreamEventData
|
|
54
|
+
): void => {
|
|
55
|
+
aggregateContent({
|
|
56
|
+
event,
|
|
57
|
+
data: data as unknown as { result: t.ToolEndEvent },
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
[GraphEvents.ON_MESSAGE_DELTA]: {
|
|
62
|
+
handle: (
|
|
63
|
+
event: GraphEvents.ON_MESSAGE_DELTA,
|
|
64
|
+
data: t.StreamEventData
|
|
65
|
+
): void => {
|
|
66
|
+
// console.log('====== ON_MESSAGE_DELTA ======');
|
|
67
|
+
console.dir(data, { depth: null });
|
|
68
|
+
aggregateContent({ event, data: data as t.MessageDeltaEvent });
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
[GraphEvents.TOOL_START]: {
|
|
72
|
+
handle: (
|
|
73
|
+
_event: string,
|
|
74
|
+
data: t.StreamEventData,
|
|
75
|
+
metadata?: Record<string, unknown>
|
|
76
|
+
): void => {
|
|
77
|
+
const toolData = data as any;
|
|
78
|
+
console.log(`\n🔧 Tool started:`);
|
|
79
|
+
console.dir({ toolData, metadata }, { depth: null });
|
|
80
|
+
|
|
81
|
+
if (toolData?.output?.name === 'tavily_search_results_json') {
|
|
82
|
+
toolCallCount++;
|
|
83
|
+
console.log(`📊 Search #${toolCallCount} initiated`);
|
|
84
|
+
} else if (toolData?.output?.name?.includes('transfer_to_')) {
|
|
85
|
+
handoffOccurred = true;
|
|
86
|
+
const specialist = toolData.name.replace('transfer_to_', '');
|
|
87
|
+
console.log(`\n🔀 Handoff initiated to: ${specialist}`);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Create the graph with research agent and report writer
|
|
94
|
+
function createGraphWithToolsAndHandoff(): t.RunConfig {
|
|
95
|
+
const agents: t.AgentInputs[] = [
|
|
96
|
+
{
|
|
97
|
+
agentId: 'research_coordinator',
|
|
98
|
+
provider: Providers.OPENAI,
|
|
99
|
+
clientOptions: {
|
|
100
|
+
modelName: 'gpt-4.1-mini',
|
|
101
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
102
|
+
},
|
|
103
|
+
tools: [new TavilySearch({ maxResults: 3 })],
|
|
104
|
+
instructions: `You are a Research Coordinator with access to web search and a report writer specialist.
|
|
105
|
+
|
|
106
|
+
Your workflow MUST follow these steps IN ORDER:
|
|
107
|
+
1. FIRST: Write an initial response acknowledging the request and outlining your research plan
|
|
108
|
+
- Explain what aspects you'll investigate
|
|
109
|
+
- Describe your search strategy
|
|
110
|
+
2. SECOND: Conduct exactly 2 web searches to gather comprehensive information
|
|
111
|
+
- Search 1: Get general information about the topic
|
|
112
|
+
- Search 2: Get specific details, recent updates, or complementary data
|
|
113
|
+
- Note: Even if your searches are unsuccessful, you MUST still proceed to handoff after EXACTLY 2 searches
|
|
114
|
+
3. FINALLY: After completing both searches, transfer to the report writer
|
|
115
|
+
- Provide the report writer with a summary of your findings
|
|
116
|
+
|
|
117
|
+
CRITICAL: You MUST write your initial response before ANY tool use. Then complete both searches before handoff.`,
|
|
118
|
+
maxContextTokens: 8000,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
agentId: 'report_writer',
|
|
122
|
+
provider: Providers.OPENAI,
|
|
123
|
+
clientOptions: {
|
|
124
|
+
modelName: 'gpt-5-mini',
|
|
125
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
126
|
+
},
|
|
127
|
+
instructions: `You are a Report Writer specialist. Your role is to:
|
|
128
|
+
1. Receive research findings from the Research Coordinator
|
|
129
|
+
2. Create a well-structured, comprehensive report
|
|
130
|
+
3. Include all key findings from the research
|
|
131
|
+
4. Format the report with clear sections and bullet points
|
|
132
|
+
5. Add a brief executive summary at the beginning
|
|
133
|
+
|
|
134
|
+
Focus on clarity, completeness, and professional presentation.`,
|
|
135
|
+
maxContextTokens: 8000,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// Create edge from research coordinator to report writer
|
|
140
|
+
const edges: t.GraphEdge[] = [
|
|
141
|
+
{
|
|
142
|
+
from: 'research_coordinator',
|
|
143
|
+
to: 'report_writer',
|
|
144
|
+
description: 'Transfer to report writer after completing research',
|
|
145
|
+
edgeType: 'handoff',
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
runId: `tools-before-handoff-${Date.now()}`,
|
|
151
|
+
graphConfig: {
|
|
152
|
+
type: 'multi-agent',
|
|
153
|
+
agents,
|
|
154
|
+
edges,
|
|
155
|
+
},
|
|
156
|
+
customHandlers,
|
|
157
|
+
returnContent: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Single test query that requires research before report writing
|
|
163
|
+
const query = `Research the latest developments in quantum computing from 2025,
|
|
164
|
+
including major breakthroughs and commercial applications.
|
|
165
|
+
I need a comprehensive report with recent findings.`;
|
|
166
|
+
|
|
167
|
+
console.log('='.repeat(60));
|
|
168
|
+
console.log(`USER QUERY: "${query}"`);
|
|
169
|
+
console.log('='.repeat(60));
|
|
170
|
+
|
|
171
|
+
// Create the graph
|
|
172
|
+
const runConfig = createGraphWithToolsAndHandoff();
|
|
173
|
+
const run = await Run.create(runConfig);
|
|
174
|
+
|
|
175
|
+
console.log('\nExpected behavior:');
|
|
176
|
+
console.log('1. Research Coordinator writes initial response/plan');
|
|
177
|
+
console.log('2. Research Coordinator performs 2 web searches');
|
|
178
|
+
console.log('3. Research Coordinator hands off to Report Writer');
|
|
179
|
+
console.log('4. Report Writer creates final report\n');
|
|
180
|
+
|
|
181
|
+
// Process with streaming
|
|
182
|
+
conversationHistory.push(new HumanMessage(query));
|
|
183
|
+
const inputs = {
|
|
184
|
+
messages: conversationHistory,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const config = {
|
|
188
|
+
configurable: {
|
|
189
|
+
thread_id: 'tools-handoff-test-1',
|
|
190
|
+
},
|
|
191
|
+
streamMode: 'values',
|
|
192
|
+
version: 'v2' as const,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const finalContentParts = await run.processStream(inputs, config);
|
|
196
|
+
const finalMessages = run.getRunMessages();
|
|
197
|
+
|
|
198
|
+
if (finalMessages) {
|
|
199
|
+
conversationHistory.push(...finalMessages);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Show results summary
|
|
203
|
+
console.log(`\n${'─'.repeat(60)}`);
|
|
204
|
+
console.log('EDGE CASE TEST RESULTS:');
|
|
205
|
+
console.log('─'.repeat(60));
|
|
206
|
+
console.log(`Tool calls before handoff: ${toolCallCount}`);
|
|
207
|
+
console.log(`Expected tool calls: 2`);
|
|
208
|
+
console.log(`Handoff occurred: ${handoffOccurred ? 'Yes ✅' : 'No ❌'}`);
|
|
209
|
+
console.log(
|
|
210
|
+
`Test status: ${toolCallCount === 2 && handoffOccurred ? 'PASSED ✅' : 'FAILED ❌'}`
|
|
211
|
+
);
|
|
212
|
+
console.log('─'.repeat(60));
|
|
213
|
+
|
|
214
|
+
// Display conversation history
|
|
215
|
+
console.log('\nConversation History:');
|
|
216
|
+
console.log('─'.repeat(60));
|
|
217
|
+
conversationHistory.forEach((msg, idx) => {
|
|
218
|
+
const role = msg.constructor.name.replace('Message', '');
|
|
219
|
+
console.log(`\n[${idx}] ${role}:`);
|
|
220
|
+
if (typeof msg.content === 'string') {
|
|
221
|
+
console.log(
|
|
222
|
+
msg.content.substring(0, 200) +
|
|
223
|
+
(msg.content.length > 200 ? '...' : '')
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error('Error in tools-before-handoff test:', error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Run the test
|
|
233
|
+
testToolsBeforeHandoff();
|
package/src/stream.ts
CHANGED
|
@@ -153,7 +153,10 @@ export class ChatModelStreamHandler implements t.EventHandler {
|
|
|
153
153
|
if (
|
|
154
154
|
chunk.tool_calls &&
|
|
155
155
|
chunk.tool_calls.length > 0 &&
|
|
156
|
-
chunk.tool_calls.every(
|
|
156
|
+
chunk.tool_calls.every(
|
|
157
|
+
(tc) =>
|
|
158
|
+
tc.id != null && tc.id !== '' && tc.name != null && tc.name !== ''
|
|
159
|
+
)
|
|
157
160
|
) {
|
|
158
161
|
hasToolCalls = true;
|
|
159
162
|
await handleToolCalls(chunk.tool_calls, metadata, graph);
|